diff --git a/.gitea/workflows/code-analysis.yml b/.gitea/workflows/code-analysis.yml index cced0da..2770702 100644 --- a/.gitea/workflows/code-analysis.yml +++ b/.gitea/workflows/code-analysis.yml @@ -146,7 +146,7 @@ jobs: -o BatchMode=yes \ -p "${{ vars.PROD_PORT }}" \ "${{ 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: needs: analyze @@ -177,4 +177,4 @@ jobs: -o BatchMode=yes \ -p "${{ vars.PROD_PORT }}" \ "${{ 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'" diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..ff6c28d --- /dev/null +++ b/.gitleaksignore @@ -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 diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index ace652e..5844f7a 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -7,12 +7,17 @@ plugins { 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") .orElse("https://harheimertc.tsschulz.de/") .get() val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL") .orElse("https://harheimertc.de/") .get() +val expectedProductionApiBaseUrl = "https://harheimertc.de/" val sentryDsn = providers.gradleProperty("SENTRY_DSN") .orElse("") .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 { namespace = "de.harheimertc" compileSdk = 35 @@ -163,6 +178,7 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") { group = "distribution" description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload." dependsOn(ensureReleaseSigning) + dependsOn(ensureProductionApiBaseUrl) dependsOn(":app:bundleProductionRelease") dependsOn(packageNativeDebugSymbolsForProductionRelease) @@ -193,6 +209,7 @@ tasks.matching { it.name in setOf("bundleProductionRelease", "assembleProductionRelease") }.configureEach { dependsOn(ensureReleaseSigning) + dependsOn(ensureProductionApiBaseUrl) } kotlin { @@ -240,6 +257,9 @@ dependencies { // Crash reporting implementation("io.sentry:sentry-android:8.42.0") + // Push notifications + implementation("com.google.firebase:firebase-messaging:25.0.2") + // Room implementation("androidx.room:room-runtime: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.datastore:datastore-preferences:1.0.0") implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") // Testing (skeleton) testImplementation("junit:junit:4.13.2") diff --git a/android-app/app/google-services.json b/android-app/app/google-services.json new file mode 120000 index 0000000..0caed9a --- /dev/null +++ b/android-app/app/google-services.json @@ -0,0 +1 @@ +../../google-services.json \ No newline at end of file diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab index 645c474..85a22bc 100644 Binary files a/android-app/app/production/release/app-production-release.aab and b/android-app/app/production/release/app-production-release.aab differ diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index d176523..39c7885 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + android:exported="true" + android:launchMode="singleTop"> + + + + + options.dsn = BuildConfig.SENTRY_DSN diff --git a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt index 1a839cc..03f6766 100644 --- a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt +++ b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt @@ -1,13 +1,20 @@ package de.harheimertc +import android.Manifest +import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.compose.setContent import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.compose.rememberNavController import de.harheimertc.ui.navigation.NavGraph import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.notifications.HarheimerNotifications import de.harheimertc.ui.theme.HarheimerTheme import androidx.hilt.navigation.compose.hiltViewModel import de.harheimertc.ui.navigation.NavigationViewModel @@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val notificationRoute = mutableStateOf(null) + + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted") + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + requestNotificationPermissionIfNeeded() + notificationRoute.value = extractNotificationRoute(intent) 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 -fun App() { +fun App( + notificationRoute: String? = null, + onNotificationRouteConsumed: () -> Unit = {}, +) { HarheimerTheme { val navController = rememberNavController() val ctx = LocalContext.current val activity = ctx as? ComponentActivity Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}") 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) } } diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 409cf76..bb920b6 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -37,6 +37,12 @@ data class SpielplanResponse( val seasons: List = emptyList(), ) data class SeasonDto(val slug: String = "", val label: String = "") +data class MannschaftenSeasonsResponse( + val success: Boolean = false, + val seasons: List = emptyList(), + val currentSeason: String = "", + val defaultSeason: String = "", +) data class SpielDto( @param:Json(name = "Termin") val termin: String = "", @param:Json(name = "HeimMannschaft") val heimMannschaft: String = "", @@ -251,6 +257,30 @@ data class ProfileUpdateRequest( val currentPassword: 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 = 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( val name: String = "", val dayMonth: String = "", @@ -584,6 +614,9 @@ interface ApiService { @GET("/api/mannschaften") suspend fun mannschaften(@Query("season") season: String? = null): Response + @GET("/api/mannschaften/seasons") + suspend fun mannschaftenSeasons(): Response + @GET("/api/config") suspend fun config(): Response @@ -651,6 +684,15 @@ interface ApiService { @retrofit2.http.PUT("/api/profile") suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response + @GET("/api/profile/notifications") + suspend fun notificationSettings(): Response + + @retrofit2.http.PUT("/api/profile/notifications") + suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response + + @POST("/api/profile/push-token") + suspend fun registerPushToken(@Body request: PushTokenRequest): Response + @GET("/api/birthdays") suspend fun birthdays(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt new file mode 100644 index 0000000..ee5652b --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt @@ -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 = _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) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt index df1737b..0927cbd 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt @@ -1,11 +1,13 @@ package de.harheimertc.data import android.content.Context +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.squareup.moshi.Moshi import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.GeneralSecurityException import javax.inject.Inject import javax.inject.Singleton @@ -14,6 +16,7 @@ class SecureOfflineCache @Inject constructor( @param:ApplicationContext private val context: Context, private val moshi: Moshi, ) { + private val tag = "SecureOfflineCache" private companion object { const val KEY_BIRTHDAYS = "birthdays" const val KEY_QTTR_VALUES = "qttr_values" @@ -29,6 +32,10 @@ class SecureOfflineCache @Inject constructor( } private val preferences by lazy { + buildEncryptedPreferences() + } + + private fun buildEncryptedPreferences() = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -39,6 +46,28 @@ class SecureOfflineCache @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 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) diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt new file mode 100644 index 0000000..a4efcde --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt @@ -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") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt new file mode 100644 index 0000000..848a4f9 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -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 = 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): 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 = when (data["type"]) { + "news" -> Destinations.MemberNews.route + else -> Destinations.Home.route + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt index d89534d..ba8fb1f 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt @@ -1,10 +1,12 @@ package de.harheimertc.repositories import android.content.Context +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import de.harheimertc.security.DeviceKeyManager +import java.security.GeneralSecurityException import javax.inject.Inject import javax.inject.Singleton @@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor( @param:ApplicationContext private val context: Context, private val deviceKeyManager: DeviceKeyManager, ) : AuthRepository { + private val tag = "AuthRepository" private val tokenKey = "auth_token" private val refreshTokenKey = "auth_refresh_token" private val sessionIdKey = "auth_session_id" private val preferences by lazy { + buildEncryptedPreferences() + } + + private fun buildEncryptedPreferences() = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 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) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt index fe80945..75e4ba1 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt @@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor( @param:ApplicationContext private val context: Context, ) { suspend fun hasPublicImages(): Result = runCatching { - val response = api.galerieList(page = 1, perPage = 1) - if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body()?.images.orEmpty().isNotEmpty() + retryOnNetworkFailure { + val response = api.galerieList(page = 1, perPage = 1) + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body()?.images.orEmpty().isNotEmpty() + } } suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result { - return try { - val resp = api.galerieList(page = page, perPage = perPage) - if (resp.isSuccessful) { - val body = resp.body() - Result.success( + return runCatching { + retryOnNetworkFailure { + val resp = api.galerieList(page = page, perPage = perPage) + if (resp.isSuccessful) { + val body = resp.body() GalleryPage( images = body?.images.orEmpty().map { it.toGalleryImage() }, pagination = body?.pagination ?: GalleryPaginationDto(), - ), - ) - } else { - Result.failure(Exception("HTTP ${resp.code()}")) + ) + } else { + error("HTTP ${resp.code()}") + } } - } catch (e: Exception) { - Result.failure(e) } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt index a004214..d5b556c 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt @@ -1,12 +1,14 @@ package de.harheimertc.repositories import android.content.Context +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.squareup.moshi.Moshi import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext import de.harheimertc.data.HomepageSectionDto +import java.security.GeneralSecurityException import javax.inject.Inject import javax.inject.Singleton @@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor( @param:ApplicationContext private val context: Context, private val moshi: Moshi, ) { + private val tag = "HomeLayoutPreferences" private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java) private val sectionListAdapter = moshi.adapter>(sectionListType) private val preferences by lazy { + buildEncryptedPreferences() + } + + private fun buildEncryptedPreferences() = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 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? { diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt index 69927fc..61b338a 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt @@ -31,19 +31,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { val diagnostics = mutableListOf() val termine = runCatching { - val response = api.termine() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/termine", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.termine() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/termine", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.termine.orEmpty() } - response.body()?.termine.orEmpty() }.onFailure { error -> captureLoadIssue("fetchHomeData.termine", error) if (diagnostics.none { it.contains("GET /api/termine") }) { @@ -58,19 +60,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { }.getOrDefault(emptyList()) val spielplanResponse = runCatching { - val response = api.spielplan() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/spielplan", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.spielplan() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/spielplan", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") + } + response.body() } - response.body() }.onFailure { error -> captureLoadIssue("fetchHomeData.spielplan", error) 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 news = runCatching { - val response = api.publicNews() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/news-public", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("News konnten nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.publicNews() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/news-public", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("News konnten nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.news.orEmpty() } - response.body()?.news.orEmpty() }.onFailure { error -> captureLoadIssue("fetchHomeData.news", error) if (diagnostics.none { it.contains("GET /api/news-public") }) { @@ -113,19 +119,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { }.getOrDefault(emptyList()) val homepageSections = runCatching { - val response = api.config() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/config", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.config() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/config", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.homepage?.sections.orEmpty() } - response.body()?.homepage?.sections.orEmpty() }.onFailure { error -> captureLoadIssue("fetchHomeData.config", error) if (diagnostics.none { it.contains("GET /api/config") }) { @@ -140,20 +148,22 @@ class HomeRepository @Inject constructor(private val api: ApiService) { }.getOrDefault(emptyList()) val heroImageUrl = runCatching { - val response = api.heroImages() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/hero-images", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.heroImages() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/hero-images", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + 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 -> captureLoadIssue("fetchHomeData.heroImages", error) 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 = runCatching { - val response = api.spielplan(season) - if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.spielplan(season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body() ?: error("Leere Antwort") + } }.onFailure { error -> Sentry.withScope { scope -> scope.setTag("repository", "HomeRepository") diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt index 0bf3f73..e3933f8 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt @@ -23,7 +23,7 @@ class LoginRepository @Inject constructor( suspend fun login(email: String, password: String): Result = runCatching { val endpoint = "api/auth/login" 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) { val body = response.errorBody()?.string().orEmpty() val serverMessage = extractServerMessage(body) @@ -71,11 +71,11 @@ class LoginRepository @Inject constructor( return@runCatching AuthStatusResponse() } - var response = api.authStatus() + var response = retryOnNetworkFailure { api.authStatus() } if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") var status = response.body() ?: AuthStatusResponse() 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.") status = response.body() ?: AuthStatusResponse() } @@ -93,15 +93,19 @@ class LoginRepository @Inject constructor( } suspend fun resetPassword(email: String): Result = runCatching { - val response = api.resetPassword(ResetPasswordRequest(email.trim())) - if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.resetPassword(ResetPasswordRequest(email.trim())) + if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun register(request: RegistrationRequest): Result = runCatching { - val response = api.register(request) - if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.register(request) + if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") + response.body() ?: error("Leere Antwort") + } } private fun extractServerMessage(raw: String): String? { diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt index 8dc1a01..5090df1 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt @@ -1,6 +1,8 @@ package de.harheimertc.repositories import de.harheimertc.data.ApiService +import de.harheimertc.data.MannschaftenSeasonsResponse +import de.harheimertc.data.SeasonDto import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -24,9 +26,19 @@ data class Mannschaft( @Singleton class MannschaftenRepository @Inject constructor(private val api: ApiService) { suspend fun fetchMannschaften(season: String? = null): Result> = runCatching { - val response = api.mannschaften(season) - if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") - parseCsv(response.body()?.string().orEmpty()) + retryOnNetworkFailure { + val response = api.mannschaften(season) + if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()) + } + } + + suspend fun fetchSeasons(): Result = 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 = csv.lineSequence() diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt new file mode 100644 index 0000000..523fab4 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt @@ -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 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 +} \ No newline at end of file diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt index 9bada4d..35b6c26 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt @@ -8,9 +8,11 @@ import javax.inject.Inject class NewsletterRepository @Inject constructor(private val api: ApiService) { suspend fun groups(): Result = runCatching { - val response = api.publicNewsletterGroups() - if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") + retryOnNetworkFailure { + val response = api.publicNewsletterGroups() + 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 = runCatching { @@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) { } suspend fun confirm(token: String): Result = runCatching { - val response = api.confirmNewsletter(token) - if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") - response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") + retryOnNetworkFailure { + val response = api.confirmNewsletter(token) + if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt new file mode 100644 index 0000000..04f1ae6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt @@ -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 = 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 = 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 { + 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, +) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt index 11d5a57..5331e13 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt @@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor( private val authRepository: AuthRepository, ) { suspend fun login(context: Context, email: String?): Result = runCatching { - 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") - ?: error("Der Server hat keine Passkey-Optionen geliefert.") + retryOnNetworkFailure { + 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") + ?: error("Der Server hat keine Passkey-Optionen geliefert.") - val credentialManager = CredentialManager.create(context) - val credentialResponse = credentialManager.getCredential( - context = context, - request = GetCredentialRequest( - credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)), - ), - ) - val credential = credentialResponse.credential as? PublicKeyCredential - ?: error("Der ausgewählte Zugang ist kein Passkey.") + val credentialManager = CredentialManager.create(context) + val credentialResponse = credentialManager.getCredential( + context = context, + request = GetCredentialRequest( + credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)), + ), + ) + val credential = credentialResponse.credential as? PublicKeyCredential + ?: error("Der ausgewählte Zugang ist kein Passkey.") - val response = api.passkeyLogin( - JSONObject() - .put("credential", JSONObject(credential.authenticationResponseJson)) - .put("client", "android") - .put("deviceName", "Harheimer TC Android-App") - .toString() - .toRequestBody(MediaTypes.json), - ) - if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.") - val body = response.body() ?: error("Leere Antwort") - val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) - ?: error("Der Server hat kein Zugriffstoken geliefert.") - authRepository.setSession(token, body.refreshToken, body.sessionId) - body + val response = api.passkeyLogin( + JSONObject() + .put("credential", JSONObject(credential.authenticationResponseJson)) + .put("client", "android") + .put("deviceName", "Harheimer TC Android-App") + .toString() + .toRequestBody(MediaTypes.json), + ) + if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.") + val body = response.body() ?: error("Leere Antwort") + val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) + ?: error("Der Server hat kein Zugriffstoken geliefert.") + authRepository.setSession(token, body.refreshToken, body.sessionId) + body + } }.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.") suspend fun list(): Result = runCatching { - val response = api.passkeys() - if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.passkeys() + if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun add(context: Context, name: String = "Android-App"): Result = runCatching { - val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) - if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") - val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") - ?: error("Der Server hat keine Passkey-Optionen geliefert.") + retryOnNetworkFailure { + val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) + if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") + val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") + ?: error("Der Server hat keine Passkey-Optionen geliefert.") - val credentialManager = CredentialManager.create(context) - val credentialResponse = credentialManager.createCredential( - context = context, - request = CreatePublicKeyCredentialRequest(optionsJson), - ) as? CreatePublicKeyCredentialResponse - ?: error("Der erstellte Zugang ist kein Passkey.") + val credentialManager = CredentialManager.create(context) + val credentialResponse = credentialManager.createCredential( + context = context, + request = CreatePublicKeyCredentialRequest(optionsJson), + ) as? CreatePublicKeyCredentialResponse + ?: error("Der erstellte Zugang ist kein Passkey.") - val response = api.registerPasskey( - JSONObject() - .put("credential", JSONObject(credentialResponse.registrationResponseJson)) - .put("name", name) - .put("client", "android") - .toString() - .toRequestBody(MediaTypes.json), - ) - if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") - response.body() ?: error("Leere Antwort") + val response = api.registerPasskey( + JSONObject() + .put("credential", JSONObject(credentialResponse.registrationResponseJson)) + .put("name", name) + .put("client", "android") + .toString() + .toRequestBody(MediaTypes.json), + ) + if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") + response.body() ?: error("Leere Antwort") + } }.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.") suspend fun remove(credentialId: String): Result = runCatching { diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt index d5ee5ca..9a4c497 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt @@ -9,14 +9,18 @@ import javax.inject.Singleton @Singleton class ProfileRepository @Inject constructor(private val api: ApiService) { suspend fun load(): Result = runCatching { - val response = api.profile() - if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.profile() + if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun save(request: ProfileUpdateRequest): Result = runCatching { - val response = api.updateProfile(request) - if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.updateProfile(request) + if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") + response.body() ?: error("Leere Antwort") + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt index 0b7c9e7..b33fa15 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt @@ -31,43 +31,49 @@ data class MeisterschaftResult( @Singleton class PublicPagesRepository @Inject constructor(private val api: ApiService) { suspend fun fetchConfig(): Result = runCatching { - val response = api.config() - if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.config() + if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun fetchSpielsysteme(): Result> = runCatching { - val response = api.spielsysteme() - if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") - parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> - if (values.size < 8) return@mapNotNull null - Spielsystem( - name = values[0], - description = values[1], - teamSize = values[2], - category = values[3], - sequence = values[5], - gameCount = values[6], - features = values[7], - ) + retryOnNetworkFailure { + val response = api.spielsysteme() + if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> + if (values.size < 8) return@mapNotNull null + Spielsystem( + name = values[0], + description = values[1], + teamSize = values[2], + category = values[3], + sequence = values[5], + gameCount = values[6], + features = values[7], + ) + } } } suspend fun fetchVereinsmeisterschaften(): Result> = runCatching { - val response = api.vereinsmeisterschaften() - if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") - parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> - if (values.size < 6) return@mapNotNull null - MeisterschaftResult( - year = values[0], - category = values[1], - rank = values[2], - playerOne = values[3], - playerTwo = values[4], - note = values[5], - imageOne = values.getOrElse(6) { "" }, - imageTwo = values.getOrElse(7) { "" }, - ) + retryOnNetworkFailure { + val response = api.vereinsmeisterschaften() + if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> + if (values.size < 6) return@mapNotNull null + MeisterschaftResult( + year = values[0], + category = values[1], + rank = values[2], + playerOne = values[3], + playerTwo = values[4], + note = values[5], + imageOne = values.getOrElse(6) { "" }, + imageTwo = values.getOrElse(7) { "" }, + ) + } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt new file mode 100644 index 0000000..ed1ef33 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt @@ -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 = runCatching { + val token = FirebaseMessaging.getInstance().token.await() + registerToken(token).getOrThrow() + } + + suspend fun registerToken(token: String): Result = 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) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt index 851ef35..5eeeace 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt @@ -9,18 +9,22 @@ import javax.inject.Singleton @Singleton class SpielplanRepository @Inject constructor(private val api: ApiService) { suspend fun fetchSpielplan(season: String? = null): Result = runCatching { - val response = api.spielplan(season) - if (!response.isSuccessful) error("HTTP ${response.code()}") - val body = response.body() ?: error("Leere Antwort") - if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") - body + retryOnNetworkFailure { + val response = api.spielplan(season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + val body = response.body() ?: error("Leere Antwort") + if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") + body + } } suspend fun fetchTeamTable(team: String, season: String? = null): Result = runCatching { - val response = api.spielplanTable(team, season) - if (!response.isSuccessful) error("HTTP ${response.code()}") - val body = response.body() ?: error("Leere Antwort") - if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.") - body + retryOnNetworkFailure { + val response = api.spielplanTable(team, season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + val body = response.body() ?: error("Leere Antwort") + if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.") + body + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt index d2926cd..0fe2e02 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt @@ -8,8 +8,10 @@ import javax.inject.Singleton @Singleton class TermineRepository @Inject constructor(private val api: ApiService) { suspend fun fetchTermine(): Result> = runCatching { - val response = api.termine() - if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body()?.termine.orEmpty() + retryOnNetworkFailure { + val response = api.termine() + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body()?.termine.orEmpty() + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt index 711b73b..cd9939e 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt @@ -8,8 +8,10 @@ import javax.inject.Singleton @Singleton class TrainingRepository @Inject constructor(private val api: ApiService) { suspend fun fetchConfig(): Result = runCatching { - val response = api.config() - if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.config() + if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 66383c7..9994519 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth 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.res.painterResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.harheimertc.BuildConfig import de.harheimertc.R @@ -42,14 +43,17 @@ private enum class MenuSection { TRAINING, NEWSLETTER, INTERN, + CMS, } private data class MenuTarget(val label: String, val route: String) +private const val LOGOUT_ROUTE = "__logout__" @Composable fun AppNavigationHeader( selectedRoute: String?, onNavigate: (String) -> Unit, + onLogout: () -> Unit = {}, webTabletNavigation: Boolean = false, navigationState: NavigationUiState = NavigationUiState(), ) { @@ -61,9 +65,9 @@ fun AppNavigationHeader( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (webTabletNavigation) { - WebTabletNavigation(selectedRoute, onNavigate, navigationState) + WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState) } else { - CompactNavigation(selectedRoute, onNavigate, navigationState) + CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState) } } } @@ -72,110 +76,56 @@ fun AppNavigationHeader( private fun CompactNavigation( selectedRoute: String?, onNavigate: (String) -> Unit, + onLogout: () -> Unit, navigationState: NavigationUiState = NavigationUiState(), ) { val routeSection = menuSection(selectedRoute) val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } val section = routeSection ?: sectionOverride.value 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 subScroll = rememberScrollState() - val cmsSubScroll = rememberScrollState() - BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.horizontalScroll(mainScroll), - 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 } - } - } + BrandRow( + loggedIn = navigationState.loggedIn, + onLogin = { onNavigate(Destinations.Login.route) }, + onLogout = onLogout, + ) - if (mainScroll.canScrollBackward) { - Text( - "◀", - color = Color(0xFFD4D4D8), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier - .align(Alignment.CenterStart) - .background(Color(0x66000000), RoundedCornerShape(8.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp), - ) + ScrollableMenuRow(scrollState = mainScroll) { + 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 (mainScroll.canScrollForward) { - Text( - "▶", - color = Color(0xFFD4D4D8), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier - .align(Alignment.CenterEnd) - .background(Color(0x66000000), RoundedCornerShape(8.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp), - ) + if (navigationState.loggedIn) { + CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN } + if (navigationState.canAccessCms) { + CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS } + } + } else { + CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null } } + CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null } } - val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } - val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList() - if (cmsChildren.any { it.route == selectedRoute }) { - cmsExpanded.value = true - } - - Row( - 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 + ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) { + subItems.forEach { item -> + SubLink(item.label, item.route == selectedRoute) { + if (item.route == LOGOUT_ROUTE) { + onLogout() + } else { + onNavigate(item.route) } - } 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 @@ -206,12 +156,13 @@ private fun CompactSectionLink( private fun WebTabletNavigation( selectedRoute: String?, onNavigate: (String) -> Unit, + onLogout: () -> Unit, navigationState: NavigationUiState, ) { - val section = menuSection(selectedRoute) - var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } - // Helper that closes the CMS submenu when navigating away - val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) } + val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } + val section = sectionOverride.value ?: menuSection(selectedRoute) + val subScroll = rememberScrollState() + Row(verticalAlignment = Alignment.CenterVertically) { Brand() Spacer(Modifier.width(16.dp)) @@ -220,74 +171,91 @@ private fun WebTabletNavigation( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) }) - MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) }) - MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) }) - MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) }) - MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) }) + MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { + sectionOverride.value = null + onNavigate(Destinations.Home.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) { - 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) { - MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) }) - } - MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) }) - TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) } - } - } - val subItems = submenu(section, navigationState) - // 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() - 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 + MainLink("Intern", section == MenuSection.INTERN, onClick = { + sectionOverride.value = MenuSection.INTERN + }) + if (navigationState.canAccessCms) { + MainLink("CMS", section == MenuSection.CMS, onClick = { + sectionOverride.value = MenuSection.CMS + onNavigate(Destinations.Cms.route) + }) } - } 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 - if (cmsExpanded.value && cmsChildren.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(top = 6.dp, bottom = 3.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - cmsChildren.forEach { child -> - SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) } + val subItems = submenu(section, navigationState) + ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) { + subItems.forEach { item -> + SubLink(item.label, item.route == selectedRoute) { + if (item.route == LOGOUT_ROUTE) { + onLogout() + } else { + onNavigate(item.route) + } } } } } @Composable -private fun BrandRow(onLogin: () -> Unit) { +private fun BrandRow( + loggedIn: Boolean, + onLogin: () -> Unit, + onLogout: () -> Unit, +) { Row(verticalAlignment = Alignment.CenterVertically) { Brand() 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 -private fun ScrollHintRow(scrollState: ScrollState) { - if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return - +private fun ScrollableMenuRow( + scrollState: ScrollState, + topPadding: Dp = 0.dp, + content: @Composable RowScope.() -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .padding(top = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(top = topPadding), verticalAlignment = Alignment.CenterVertically, ) { Text( if (scrollState.canScrollBackward) "◀" else "", color = Color(0xFFD4D4D8), 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( if (scrollState.canScrollForward) "▶" else "", color = Color(0xFFD4D4D8), 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.NewsletterConfirmed.route, Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER + Destinations.MemberArea.route, Destinations.Members.route, Destinations.Qttr.route, Destinations.MemberNews.route, Destinations.Profile.route, - Destinations.MemberApi.route, + Destinations.NotificationSettings.route, + Destinations.MemberApi.route -> MenuSection.INTERN + Destinations.CmsStartseite.route, Destinations.CmsInhalte.route, Destinations.CmsVereinsmeisterschaften.route, @@ -447,7 +429,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.CmsEinstellungen.route, Destinations.CmsBenutzer.route, Destinations.CmsPasswordResetDiagnostics.route, - Destinations.Cms.route -> MenuSection.INTERN + Destinations.Cms.route -> MenuSection.CMS + else -> null }.let { section -> if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section @@ -464,43 +447,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List listOf( MenuTarget("Übersicht", Destinations.Mannschaften.route), ) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf( MenuTarget("Spielpläne", Destinations.Spielplan.route), MenuTarget("Spielsysteme", Destinations.Spielsysteme.route), ) + MenuSection.TRAINING -> listOf( MenuTarget("Trainingszeiten", Destinations.Training.route), MenuTarget("Trainer", Destinations.Trainer.route), MenuTarget("Anfänger", Destinations.Anfaenger.route), MenuTarget("TT-Regeln", Destinations.Regeln.route), ) + MenuSection.NEWSLETTER -> listOf( MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route), MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route), MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route), ) + MenuSection.INTERN -> buildList { add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route)) add(MenuTarget("QTTR", Destinations.Qttr.route)) add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route)) + add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.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)) - if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) - if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route)) - if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route)) - if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route)) - if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route)) + } + + MenuSection.CMS -> buildList { + if (state.canAccessFullCms) { + add(MenuTarget("Übersicht", Destinations.Cms.route)) + add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) + add(MenuTarget("Inhalte", Destinations.CmsInhalte.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.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() } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt new file mode 100644 index 0000000..809fb2f --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt @@ -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)) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt index 832b3b9..559d590 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt @@ -10,8 +10,12 @@ sealed class Destinations(val route: String) { object Links : Destinations("verein/links") object Impressum : Destinations("impressum") object Mannschaften : Destinations("mannschaften") - object MannschaftDetail : Destinations("mannschaften/{slug}") { - fun create(slug: String): String = "mannschaften/$slug" + object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") { + 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}") { fun create(slug: String): String = "mannschaft/$slug" @@ -39,6 +43,7 @@ sealed class Destinations(val route: String) { object Qttr : Destinations("intern/qttr") object MemberNews : Destinations("intern/news") object Profile : Destinations("intern/profil") + object NotificationSettings : Destinations("intern/benachrichtigungen") object MemberApi : Destinations("intern/api") object CmsStartseite : Destinations("cms/startseite") object CmsInhalte : Destinations("cms/inhalte") diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index e5d778f..1b014d4 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -2,15 +2,22 @@ package de.harheimertc.ui.navigation import androidx.compose.runtime.Composable 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.Column 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.graphics.Color import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -26,7 +33,9 @@ fun NavGraph( val backStackEntry = navController.currentBackStackEntryAsState().value val route = backStackEntry?.destination?.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 val navigationState by navigationViewModel.state.collectAsState() LaunchedEffect(currentRoute) { @@ -35,10 +44,27 @@ fun NavGraph( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val persistentNavigation = maxWidth >= 600.dp 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) { AppNavigationHeader( selectedRoute = currentRoute, onNavigate = navController::navigateTopLevel, + onLogout = { + navigationViewModel.logout { + navController.navigate(Destinations.Home.route) { + launchSingleTop = true + } + } + }, webTabletNavigation = true, navigationState = navigationState, ) @@ -52,6 +78,8 @@ fun NavGraph( de.harheimertc.ui.screens.home.HomeScreen( navController = navController, showNavigationHeader = !persistentNavigation, + navigationViewModel = navigationViewModel, + viewModel = hiltViewModel(), ) } composable(Destinations.VereinAbout.route) { @@ -111,9 +139,13 @@ fun NavGraph( composable("mannschaften/jugend") { 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( slug = entry.arguments?.getString("slug").orEmpty(), + season = entry.arguments?.getString("season"), navController = navController, showBackNavigation = !persistentNavigation, ) @@ -121,6 +153,7 @@ fun NavGraph( composable(Destinations.MannschaftLegacyDetail.route) { entry -> de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( slug = entry.arguments?.getString("slug").orEmpty(), + season = null, navController = navController, showBackNavigation = !persistentNavigation, ) @@ -253,6 +286,7 @@ fun NavGraph( de.harheimertc.ui.screens.memberarea.MemberAreaScreen( navController = navController, showBackNavigation = !persistentNavigation, + navigationState = navigationState, ) } composable(Destinations.Members.route) { @@ -279,6 +313,13 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable(Destinations.NotificationSettings.route) { + de.harheimertc.ui.screens.notifications.NotificationSettingsScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + navigationState = navigationState, + ) + } composable(Destinations.MemberApi.route) { de.harheimertc.ui.screens.memberarea.MemberApiScreen( navController = navController, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt index 4e7741c..84ed639 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt @@ -3,10 +3,13 @@ package de.harheimertc.ui.navigation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.LoginRepository import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.MannschaftenRepository +import de.harheimertc.repositories.PushTokenRepository import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,10 +21,13 @@ data class NavigationUiState( val hasGalleryImages: Boolean = false, val loggedIn: Boolean = false, val roles: Set = emptySet(), + val connectionNote: String? = null, ) { 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 canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") } + val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter } @@ -30,12 +36,24 @@ class NavigationViewModel @Inject constructor( private val mannschaftenRepository: MannschaftenRepository, private val galleryRepository: GalleryRepository, private val loginRepository: LoginRepository, + private val authRepository: AuthRepository, + private val connectivityMonitor: ConnectivityMonitor, + private val pushTokenRepository: PushTokenRepository, ) : ViewModel() { private val _state = MutableStateFlow(NavigationUiState()) val state: StateFlow = _state init { 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() { @@ -44,22 +62,54 @@ class NavigationViewModel @Inject constructor( val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) } val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) } val status = auth.await() + val hasStoredSession = !authRepository.getToken().isNullOrBlank() + val loggedIn = hasStoredSession || status.isLoggedIn _state.value = NavigationUiState( teams = teams.await(), hasGalleryImages = gallery.await(), - loggedIn = status.isLoggedIn, - roles = (status.roles + status.user?.roles.orEmpty()).toSet(), + loggedIn = loggedIn, + roles = status.navigationRoles(), + connectionNote = null, ) + if (loggedIn) registerPushToken() } } fun refreshSession() { viewModelScope.launch { val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) + val hasStoredSession = !authRepository.getToken().isNullOrBlank() + val loggedIn = hasStoredSession || status.isLoggedIn _state.value = _state.value.copy( - loggedIn = status.isLoggedIn, - roles = (status.roles + status.user?.roles.orEmpty()).toSet(), + loggedIn = loggedIn, + 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 = buildSet { + addAll(roles) + role?.takeIf { it.isNotBlank() }?.let(::add) + addAll(user?.roles.orEmpty()) +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt index e30e3d9..6264c27 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Checkbox import androidx.compose.material3.Text @@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsSaveRequest import de.harheimertc.ui.components.FormMessages +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.navigation.Destinations import kotlinx.coroutines.launch @@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie } CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") { - if (state.loading) item { CircularProgressIndicator() } + if (state.loading) item { LoadingState("News werden geladen...") } item { Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt index 260cb39..1ef296f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -50,6 +49,7 @@ import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetStepDto import de.harheimertc.ui.components.FormMessages +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.navigation.Destinations import de.harheimertc.repositories.MeisterschaftResult @@ -67,7 +67,7 @@ import java.util.Locale fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { val state by viewModel.state.collectAsState() 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) } } } @@ -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.") { when { - state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } + state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") } else -> { item { Button( @@ -171,7 +171,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") { when { - state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } + state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") } else -> { item { 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") { when { - state.loading -> item { CircularProgressIndicator(color = Primary600) } + state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") } else -> { item { 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") { when { - state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } + state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") } else -> { item { Button( @@ -555,7 +555,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { val state by viewModel.state.collectAsState() 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.") } items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) } } @@ -592,7 +592,7 @@ fun CmsNewsletterScreen( var grpTargetGroup by remember { mutableStateOf("") } var grpSendToExternal by remember { mutableStateOf(true) } CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") { - if (state.loading) item { CircularProgressIndicator(color = Primary600) } + if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") } item { if (canWrite) Button(onClick = { editingNewsletter = null @@ -769,7 +769,7 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") { when { - state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } + state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") } else -> { item { Button( @@ -883,7 +883,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi } if (state.loading) { - item { CircularProgressIndicator(color = Primary600) } + item { LoadingState("Diagnosedaten werden geladen...") } } if (state.passwordResetSearchTerm.isNotBlank()) { @@ -1073,7 +1073,7 @@ private fun CmsConfigPage( ) { CmsPage(navController, showBackNavigation, title, subtitle) { if (config == null) { - item { CircularProgressIndicator(color = Primary600) } + item { LoadingState("Konfiguration wird geladen...") } } else { item { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index 7d66132..328e728 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.CmsUserDto import de.harheimertc.data.ConfigResponse import de.harheimertc.data.ContactRequestDto @@ -43,12 +44,22 @@ data class CmsUiState( @HiltViewModel class CmsViewModel @Inject constructor( private val repository: CmsRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(CmsUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun load() { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt index d9c2515..2d16dfe 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import de.harheimertc.R import de.harheimertc.ui.components.FormMessages +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.ImageGrid @Composable @@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { if (loading) { - CircularProgressIndicator() + LoadingState("Galerie wird geladen...") } else if (images.isEmpty()) { Text(text = stringResource(R.string.gallery_empty)) } else { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt index 409eab2..0557bcd 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel import de.harheimertc.ui.navigation.NavigationViewModel import androidx.navigation.NavController import coil.compose.AsyncImage @@ -77,9 +76,9 @@ import java.util.Locale fun HomeScreen( navController: NavController, showNavigationHeader: Boolean = true, - viewModel: HomeViewModel = hiltViewModel(), + navigationViewModel: NavigationViewModel, + viewModel: HomeViewModel, ) { - val navigationViewModel: NavigationViewModel = hiltViewModel() val navigationState by navigationViewModel.state.collectAsState() val state by viewModel.state.collectAsState() var selectedNews by remember { mutableStateOf(null) } @@ -107,6 +106,13 @@ fun HomeScreen( AppNavigationHeader( selectedRoute = Destinations.Home.route, onNavigate = navController::navigate, + onLogout = { + navigationViewModel.logout { + navController.navigate(Destinations.Home.route) { + launchSingleTop = true + } + } + }, navigationState = navigationState, ) } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt index 0ca022d..a77ff90 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt @@ -49,7 +49,7 @@ fun PasswordResetScreen( val state by viewModel.state.collectAsState() AuthFormPage( 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) }, showBackNavigation = showBackNavigation, ) { @@ -68,7 +68,7 @@ fun PasswordResetScreen( TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) { 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.") } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt index a22b6dc..88e95f8 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt @@ -17,8 +17,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button 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.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.data.SpielDto import de.harheimertc.data.LeagueTableRowDto import de.harheimertc.repositories.Mannschaft +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.theme.Accent100 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 @@ -64,13 +70,22 @@ fun MannschaftenScreen( BackLink(navController, showBackNavigation) 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)) + if (state.seasons.isNotEmpty()) { + SeasonSelector( + seasons = state.seasons, + selectedSeason = state.selectedSeason, + onSeasonSelected = viewModel::selectSeason, + modifier = Modifier.padding(top = 14.dp), + ) + } } when { + state.seasonsLoading -> item { Loading() } state.loading -> item { Loading() } state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) } state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) } 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 { @@ -88,6 +103,38 @@ fun MannschaftenScreen( } } +@Composable +private fun SeasonSelector( + seasons: List, + 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 private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) { Surface( @@ -114,13 +161,14 @@ private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) { @Composable fun MannschaftDetailScreen( slug: String, + season: String?, navController: NavController, showBackNavigation: Boolean, viewModel: MannschaftDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) } - LaunchedEffect(slug) { viewModel.load(slug) } + var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) } + LaunchedEffect(slug, season) { viewModel.load(slug, season) } LazyColumn( modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), 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 private fun Loading() { - Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Primary600) - } + LoadingState("Mannschaftsdaten werden geladen...") } @Composable diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt index c7ebb88..039bbd5 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.SpielDto import de.harheimertc.data.LeagueTableRowDto +import de.harheimertc.data.SeasonDto import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.SpielplanRepository @@ -17,6 +18,9 @@ data class MannschaftenUiState( val loading: Boolean = true, val error: String? = null, val teams: List = emptyList(), + val seasons: List = emptyList(), + val selectedSeason: String = "", + val seasonsLoading: Boolean = false, ) @HiltViewModel @@ -27,17 +31,76 @@ class MannschaftenViewModel @Inject constructor( val state: StateFlow = _state init { - load() + loadSeasonsAndMannschaften() } fun load() { viewModelScope.launch { - _state.value = MannschaftenUiState(loading = true) - repository.fetchMannschaften() - .onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) } - .onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") } + val season = _state.value.selectedSeason.ifBlank { null } + _state.value = _state.value.copy(loading = true, error = null) + repository.fetchMannschaften(season) + .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( @@ -58,35 +121,38 @@ class MannschaftDetailViewModel @Inject constructor( ) : ViewModel() { private val _state = MutableStateFlow(MannschaftDetailUiState()) val state: StateFlow = _state - private var loadedSlug: String? = null + private var loadedKey: String? = null - fun load(slug: String) { - if (loadedSlug == slug) return - loadedSlug = slug + fun load(slug: String, season: String? = null) { + val selectedSeason = season?.takeIf { it.isNotBlank() } + val key = "$slug|${selectedSeason.orEmpty()}" + if (loadedKey == key) return + loadedKey = key viewModelScope.launch { - _state.value = MannschaftDetailUiState(loading = true) - val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug } + _state.value = MannschaftDetailUiState(loading = true, season = selectedSeason) + val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug } if (team == null) { _state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.") return@launch } - spielplanRepository.fetchSpielplan() + spielplanRepository.fetchSpielplan(selectedSeason) .onSuccess { plan -> _state.value = MannschaftDetailUiState( loading = false, team = team, matches = plan.data.filter { matchesTeam(it, team.mannschaft) }, - season = plan.season, + season = plan.season ?: selectedSeason, ) if (team.informationenLink.isNotBlank()) { - loadTable(team, plan.season) + loadTable(team, plan.season ?: selectedSeason) } } .onFailure { _state.value = MannschaftDetailUiState( loading = false, team = team, - matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.", + season = selectedSeason, + matchesError = "Der Spielplan konnte nicht geladen werden.", ) } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt index a1521d6..7aa215f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -39,6 +38,7 @@ import androidx.navigation.NavController import de.harheimertc.data.MemberDto import de.harheimertc.data.NewsDto import de.harheimertc.data.QttrRowDto +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.RichText import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 @@ -163,7 +163,7 @@ fun MembersScreen( } when { - state.loading -> item { CircularProgressIndicator(color = Primary600) } + state.loading -> item { LoadingState("Mitglieder werden geladen...") } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } else -> if (viewMode == "table") { @@ -200,7 +200,7 @@ fun MemberNewsScreen( val state by viewModel.state.collectAsState() MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") { 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.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) } else -> items(state.news.size) { index -> NewsCard(state.news[index]) } @@ -269,7 +269,7 @@ fun QttrScreen( } } 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.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)) } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt index b42de74..3ad7b98 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.AuthStatusResponse import de.harheimertc.data.MemberDto import de.harheimertc.data.NewsDto @@ -24,12 +25,22 @@ data class MembersUiState( @HiltViewModel class MembersViewModel @Inject constructor( private val repository: MemberAreaRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(MembersUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun updateQuery(query: String) { @@ -87,12 +98,22 @@ data class MemberNewsUiState( @HiltViewModel class MemberNewsViewModel @Inject constructor( private val repository: MemberAreaRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(MemberNewsUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun load() { @@ -118,12 +139,22 @@ data class QttrUiState( class QttrViewModel @Inject constructor( private val repository: MemberAreaRepository, private val loginRepository: LoginRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(QttrUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun load() { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt index 555d954..1712b0d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -27,8 +26,11 @@ 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.BuildConfig import de.harheimertc.data.BirthdayDto +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.ui.navigation.NavigationUiState import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent700 @@ -40,6 +42,7 @@ import de.harheimertc.ui.theme.Primary600 fun MemberAreaScreen( navController: NavController, showBackNavigation: Boolean, + navigationState: NavigationUiState = NavigationUiState(), viewModel: MemberAreaViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -63,6 +66,12 @@ fun MemberAreaScreen( MemberAreaCardGrid(navController) } + if (navigationState.isAdmin) { + item { + ServerInfoCard() + } + } + item { BirthdayCard( 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 private fun MemberAreaCardGrid(navController: NavController) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -83,6 +102,12 @@ private fun MemberAreaCardGrid(navController: NavController) { marker = "P", 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( title = "Mitglieder", description = "Kontaktdaten der Vereinsmitglieder", @@ -158,8 +183,7 @@ private fun BirthdayCard( when { loading -> { - CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp)) - Text("Lade...", color = Accent500) + LoadingState("Geburtstage werden geladen...") } error != null -> { Text(error, color = MaterialTheme.colorScheme.error) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt index 26d4e8e..6fb9c3f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.BirthdayDto import de.harheimertc.repositories.MemberAreaRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -19,12 +20,22 @@ data class MemberAreaUiState( @HiltViewModel class MemberAreaViewModel @Inject constructor( private val repository: MemberAreaRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(MemberAreaUiState()) val state: StateFlow = _state init { loadBirthdays() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + loadBirthdays() + } + wasOnline = online + } + } } fun loadBirthdays() { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt index d9f9ae6..8c63b06 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.data.NewsletterGroupDto +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.theme.Accent100 @@ -93,8 +93,7 @@ fun NewsletterConfirmScreen( NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") { when { state.loading -> { - CircularProgressIndicator(color = Primary600) - Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700) + LoadingState("Newsletter-Anmeldung wird bestätigt...") } state.error != null -> { 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)) } if (state.loading) { - item { CircularProgressIndicator(color = Primary600) } + item { LoadingState("Newsletter-Daten werden geladen...") } } else { item { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt new file mode 100644 index 0000000..7b1d443 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -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, + seasons: List, + 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) + } + } + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt new file mode 100644 index 0000000..8cd156c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt @@ -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 = emptyList(), + val seasons: List = 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 = _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, 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, + ) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt index e4ef8fe..b526b0a 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt @@ -38,6 +38,7 @@ import androidx.navigation.NavController import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.components.ValidatedTextField +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.theme.Primary600 @Composable @@ -67,9 +68,7 @@ fun ProfileScreen( if (state.loading) { item { - Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Primary600) - } + LoadingState("Profil wird geladen...") } } else { item { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt index 1e8bee5..5b29ab2 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.navigation.NavController import de.harheimertc.BuildConfig +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.RichText import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent900 @@ -68,9 +68,7 @@ internal fun PublicCard(title: String? = null, content: @Composable () -> Unit) @Composable internal fun PublicLoading() { - Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Primary600) - } + LoadingState() } @Composable diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt index b7c69ac..36a6eb0 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button 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 @@ -43,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.data.SeasonDto import de.harheimertc.data.SpielDto +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent200 import de.harheimertc.ui.theme.Accent500 @@ -228,10 +228,7 @@ private fun MatchRow(game: SpielDto) { @Composable private fun LoadingPlan() { - Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Primary600) - Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp)) - } + LoadingState("Spielpläne werden geladen...") } @Composable diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt index f241ed7..e2ca930 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.data.TerminDto +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent900 @@ -125,10 +125,7 @@ private fun TerminCard(termin: TerminDto) { @Composable private fun LoadingPanel() { - Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Primary600) - Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp)) - } + LoadingState("Termine werden geladen...") } @Composable diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt index dc2e429..3a7f7bc 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -32,6 +31,7 @@ import androidx.navigation.NavController import de.harheimertc.data.TrainingTimeDto import de.harheimertc.data.TrainerDto import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent900 @@ -193,9 +193,7 @@ private fun TrainerCard(trainer: TrainerDto) { @Composable private fun Loading() { - Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Primary600) - } + LoadingState("Trainingsdaten werden geladen...") } @Composable diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts index 4672809..4c1affa 100644 --- a/android-app/build.gradle.kts +++ b/android-app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { 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.plugin.compose") version "2.3.21" apply false + id("com.google.gms.google-services") version "4.4.4" apply false } buildscript { diff --git a/android-app/gradle.properties b/android-app/gradle.properties index acf7727..23c1fef 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=19 -ANDROID_VERSION_NAME=0.9.14 +ANDROID_VERSION_CODE=25 +ANDROID_VERSION_NAME=0.9.20 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/components/PublicNews.vue b/components/PublicNews.vue index 2b89552..8041a86 100644 --- a/components/PublicNews.vue +++ b/components/PublicNews.vue @@ -78,53 +78,62 @@ - -
-
- -
-
-
- - {{ formatDate(selectedNews.created) }} -
-

- {{ selectedNews.title }} -

-
- -
+ + + -
-
+ + diff --git a/deploy-production.sh b/deploy-production.sh index ea5e665..1788df6 100755 --- a/deploy-production.sh +++ b/deploy-production.sh @@ -279,9 +279,25 @@ use_project_node ensure_node_version install_dependencies_if_needed -# 4. Remove old build (but keep data!) +# 4. Stop running apps before replacing build artifacts 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 if [ -d ".output" ]; then echo " Removing .output directory..." diff --git a/deploy-test.sh b/deploy-test.sh index 2751283..237afdf 100755 --- a/deploy-test.sh +++ b/deploy-test.sh @@ -285,9 +285,19 @@ use_project_node ensure_node_version install_dependencies_if_needed -# 4. Remove old build (but keep data!) +# 4. Stop running app before replacing build artifacts 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 if [ -d ".output" ]; then echo " Removing .output directory..." diff --git a/google-services.json b/google-services.json new file mode 100644 index 0000000..548aa5c --- /dev/null +++ b/google-services.json @@ -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" +} \ No newline at end of file diff --git a/harheimertc.config.cjs b/harheimertc.config.cjs index 8b723bf..47eae05 100644 --- a/harheimertc.config.cjs +++ b/harheimertc.config.cjs @@ -7,6 +7,8 @@ try { } // 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) { return { NODE_ENV: 'production', @@ -35,7 +37,10 @@ function createEnv(port) { WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID, 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 } } diff --git a/harheimertc.test.config.cjs b/harheimertc.test.config.cjs index e9dcb0f..3f72606 100644 --- a/harheimertc.test.config.cjs +++ b/harheimertc.test.config.cjs @@ -8,6 +8,8 @@ try { } // 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) { return { NODE_ENV: process.env.NODE_ENV || 'development', @@ -37,7 +39,10 @@ function createEnv(port) { WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID, 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 } } diff --git a/package-lock.json b/package-lock.json index 21c20bd..21373c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "harheimertc-website", - "version": "1.7.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "harheimertc-website", - "version": "1.7.0", + "version": "1.8.1", "hasInstallScript": true, "dependencies": { "@pinia/nuxt": "^0.11.2", @@ -37,7 +37,7 @@ "postcss": "^8.5.12", "supertest": "^7.1.0", "tailwindcss": "^3.4.0", - "vitest": "^4.0.16", + "vitest": "^4.1.8", "vue-eslint-parser": "^10.2.0" }, "engines": { @@ -5418,31 +5418,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", - "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", - "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.16", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -5451,7 +5451,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -5463,26 +5463,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", - "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", - "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -5490,13 +5490,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", - "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5505,9 +5506,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", - "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -5515,14 +5516,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", - "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -6666,9 +6668,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -7733,10 +7735,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, + "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/es-object-atoms": { @@ -8164,9 +8165,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9014,12 +9015,6 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", @@ -12985,9 +12980,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -13961,9 +13956,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -14624,12 +14619,6 @@ "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": { "version": "11.3.3", "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz", @@ -15172,31 +15161,31 @@ } }, "node_modules/vitest": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", - "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.16", - "@vitest/mocker": "4.0.16", - "@vitest/pretty-format": "4.0.16", - "@vitest/runner": "4.0.16", - "@vitest/snapshot": "4.0.16", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -15212,12 +15201,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.16", - "@vitest/browser-preview": "4.0.16", - "@vitest/browser-webdriverio": "4.0.16", - "@vitest/ui": "4.0.16", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -15238,6 +15230,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -15246,9 +15244,19 @@ }, "jsdom": { "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", diff --git a/package.json b/package.json index 44b67c4..df76049 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harheimertc-website", - "version": "1.8.0", + "version": "1.8.1", "description": "Moderne Webseite für den Harheimer Tischtennis Club", "private": true, "type": "module", @@ -16,9 +16,12 @@ "start": "nuxt start --port 3100", "postinstall": "nuxt prepare", "test": "vitest run", + "test:data-rotation": "vitest run tests/data-file-rotation.spec.ts", "check-security": "node scripts/verify-no-public-writes.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", + "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", "import-spielplan": "node scripts/import-spielplan.js", "publish-spielplan": "node scripts/publish-imported-spielplan.js", @@ -43,7 +46,6 @@ "pinia": "^3.0.3", "quill": "^2.0.2", "sharp": "^0.34.5", - "vue": "^3.5.22" }, "devDependencies": { @@ -58,7 +60,7 @@ "postcss": "^8.5.12", "supertest": "^7.1.0", "tailwindcss": "^3.4.0", - "vitest": "^4.0.16", + "vitest": "^4.1.8", "vue-eslint-parser": "^10.2.0" }, "overrides": { diff --git a/pages/passwort-vergessen.vue b/pages/passwort-vergessen.vue index 6101a32..45d9f40 100644 --- a/pages/passwort-vergessen.vue +++ b/pages/passwort-vergessen.vue @@ -6,7 +6,7 @@ Passwort zurücksetzen

- 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

diff --git a/pages/passwort-zuruecksetzen.vue b/pages/passwort-zuruecksetzen.vue new file mode 100644 index 0000000..80137e9 --- /dev/null +++ b/pages/passwort-zuruecksetzen.vue @@ -0,0 +1,125 @@ + + + diff --git a/plugins/auth-sync.client.js b/plugins/auth-sync.client.js new file mode 100644 index 0000000..00469e4 --- /dev/null +++ b/plugins/auth-sync.client.js @@ -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() + } + }) + } +}) \ No newline at end of file diff --git a/scripts/data-backup-restore.js b/scripts/data-backup-restore.js new file mode 100644 index 0000000..cc52691 --- /dev/null +++ b/scripts/data-backup-restore.js @@ -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 ') + 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 ') + 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 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 ') + 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) +}) diff --git a/server/api/auth/reset-password.post.js b/server/api/auth/reset-password.post.js index 1541ff7..c05865c 100644 --- a/server/api/auth/reset-password.post.js +++ b/server/api/auth/reset-password.post.js @@ -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 crypto from 'crypto' import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' import { writeAuditLog } from '../../utils/audit-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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + export default defineEventHandler(async (event) => { const requestId = crypto.randomUUID() let emailKey = '' @@ -34,7 +69,6 @@ export default defineEventHandler(async (event) => { }) } - // Rate Limiting (IP + Account) await logStep('rate_limit', 'checking') try { assertRateLimit(event, { @@ -57,7 +91,6 @@ export default defineEventHandler(async (event) => { } await logStep('rate_limit', 'passed') - // Find user let users try { users = await readUsers() @@ -67,7 +100,6 @@ export default defineEventHandler(async (event) => { } const user = users.find(u => normalizeResetEmail(u.email) === emailKey) - // Always return success (security: don't reveal if email exists) if (!user) { await logStep('user_lookup', 'not_found') 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 }) - // 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 smtpPass = process.env.SMTP_PASS - + if (!smtpUser || !smtpPass) { 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_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : '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: ` -

Passwort zurücksetzen

-

Hallo ${user.name},

-

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

-

Ihr temporäres Passwort lautet: ${tempPassword}

-

Bitte melden Sie sich damit an und ändern Sie Ihr Passwort im Mitgliederbereich.

-
-

Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.

-
-

Mit sportlichen Grüßen,
Ihr Harheimer TC

- ` - } - - 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. - user.password = hashedPassword - user.passwordResetRequired = true + await logStep('mail_configuration', 'passed', { userId: user.id }) + + 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) - let passwordStored = false + let tokenStored = false try { - passwordStored = await writeUsers(updatedUsers) + tokenStored = await writeUsers(updatedUsers) } catch (error) { - await logStep('password_storage', 'failed', { userId: user.id, error }) + await logStep('token_storage', 'failed', { userId: user.id, error }) throw error } - if (!passwordStored) { - await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' }) - throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden') + if (!tokenStored) { + await logStep('token_storage', 'failed', { userId: user.id, reason: 'write_failed' }) + 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: ` +

Passwort zurücksetzen

+

Hallo ${displayName},

+

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

+

Bitte klicken Sie auf den folgenden Link und vergeben Sie dort ein neues Passwort. Der Link ist ${RESET_TOKEN_TTL_MINUTES} Minuten gültig:

+

Neues Passwort setzen

+

Ihr bisheriges Passwort bleibt gültig, bis Sie über diesen Link ein neues Passwort gesetzt haben.

+
+

Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.

+
+

Mit sportlichen Grüßen,
Ihr Harheimer TC

+ ` + } + + await logStep('mail_send', 'started', { userId: user.id }) try { - await revokeRefreshSessionsForUser(user.id, 'password_reset') + await transporter.sendMail(mailOptions) } catch (error) { - await logStep('session_revocation', 'failed', { userId: user.id, error }) + await logStep('mail_send', 'failed', { userId: user.id, 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] }) 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) { await logStep('request_completed', 'failed', { error }) console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' }) - // Don't reveal errors to prevent email enumeration return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' diff --git a/server/api/auth/reset-password/complete.post.js b/server/api/auth/reset-password/complete.post.js new file mode 100644 index 0000000..df89f66 --- /dev/null +++ b/server/api/auth/reset-password/complete.post.js @@ -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.' + } +}) diff --git a/server/api/birthdays.get.js b/server/api/birthdays.get.js index 657bc2d..2b7b45b 100644 --- a/server/api/birthdays.get.js +++ b/server/api/birthdays.get.js @@ -1,5 +1,5 @@ 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) function getUpcomingBirthdays(entries, daysAhead = 28) { @@ -53,10 +53,14 @@ export default defineEventHandler(async (event) => { const manualMembers = await readMembers() 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 const candidates = [] 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 hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== '' const isAccepted = hasExplicitAcceptanceFlag @@ -73,7 +77,7 @@ export default defineEventHandler(async (event) => { } for (const u of registeredUsers) { - if (!u.active) continue + if (!u.active || isHiddenUser(u)) continue const vis = u.visibility || {} const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' }) diff --git a/server/api/cms/password-reset-diagnostics.get.js b/server/api/cms/password-reset-diagnostics.get.js index dbaa679..b50fc36 100644 --- a/server/api/cms/password-reset-diagnostics.get.js +++ b/server/api/cms/password-reset-diagnostics.get.js @@ -1,4 +1,4 @@ -import { getUserFromToken, hasRole, readUsers } from '../../utils/auth.js' +import { getUserFromToken, hasRole, readUsers, isHiddenUser } from '../../utils/auth.js' import { fingerprintResetEmail, normalizeResetEmail, @@ -59,17 +59,20 @@ export default defineEventHandler(async (event) => { const email = normalizeResetEmail(query.email) const failedOnly = query.failedOnly !== 'false' 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 filteredLogs = email + const filteredLogs = (email ? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email)) - : logs + : logs) + .filter(entry => !hiddenEmailFingerprints.has(entry.emailFingerprint)) const attempts = summarizeAttempts(filteredLogs) .filter(attempt => !failedOnly || attempt.failed) let matchingUsers = [] if (email) { const term = email.toLowerCase() - matchingUsers = users + matchingUsers = visibleUsers .filter(user => { const userEmail = normalizeResetEmail(user.email) const name = String(user.name || '').toLowerCase() diff --git a/server/api/cms/users/list.get.js b/server/api/cms/users/list.get.js index a844064..79b0d61 100644 --- a/server/api/cms/users/list.get.js +++ b/server/api/cms/users/list.get.js @@ -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) => { try { @@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => { // Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen. 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 roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied']) diff --git a/server/api/contact.post.js b/server/api/contact.post.js index 1eb1e3c..7bf2307 100644 --- a/server/api/contact.post.js +++ b/server/api/contact.post.js @@ -2,7 +2,7 @@ import nodemailer from 'nodemailer' import { promises as fs } from 'fs' import path from 'path' 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 // filename is always a hardcoded constant ('config.json'), never user input @@ -54,6 +54,7 @@ async function collectRecipients(config) { try { const users = await readUsers() for (const rawUser of users) { + if (isHiddenUser(rawUser)) continue const user = migrateUserRoles({ ...rawUser }) const roles = Array.isArray(user.roles) ? user.roles : [] if (roles.includes('trainer') && user.email && String(user.email).trim()) { diff --git a/server/api/members.get.js b/server/api/members.get.js index a08e6c0..e588122 100644 --- a/server/api/members.get.js +++ b/server/api/members.get.js @@ -1,6 +1,6 @@ import { verifyToken, getUserFromToken, hasRole } from '../utils/auth.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) => { try { @@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => { // Skip applications that are not yet accepted continue } - const normalizedEmail = member.email?.toLowerCase().trim() || '' + const normalizedEmail = normalizeUserEmail(member.email) const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim() 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) 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() || '' // Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name @@ -208,7 +210,10 @@ export default defineEventHandler(async (event) => { const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false // 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 => { // Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them const visibility = member.visibility || {} diff --git a/server/api/mitgliederbereich/qttr.get.js b/server/api/mitgliederbereich/qttr.get.js index d944bc3..85ad60c 100644 --- a/server/api/mitgliederbereich/qttr.get.js +++ b/server/api/mitgliederbereich/qttr.get.js @@ -1,8 +1,7 @@ import { readFile } from 'fs/promises' 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 { readUsers } from '../../utils/auth.js' const QTTR_FILE = getServerDataPath('qttr-values.json') @@ -62,15 +61,27 @@ export default defineEventHandler(async (event) => { readMembers(), 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 { ...payload, rows: Array.isArray(payload.rows) - ? payload.rows.map((row) => ({ - ...row, - birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' - })) + ? payload.rows + .filter(row => !hiddenNames.has(normalizeName(row.playerName))) + .map((row) => ({ + ...row, + birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' + })) : [] } } catch (error) { diff --git a/server/api/news.post.js b/server/api/news.post.js index 9c32d16..e0e51c6 100644 --- a/server/api/news.post.js +++ b/server/api/news.post.js @@ -1,5 +1,6 @@ import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { saveNews } from '../utils/news.js' +import { sendNewNewsPush } from '../utils/push-notifications.js' export default defineEventHandler(async (event) => { try { @@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => { }) } - await saveNews({ + const newsEntry = { id: id || undefined, title, content, @@ -49,7 +50,17 @@ export default defineEventHandler(async (event) => { expiresAt: expiresAt || undefined, isHidden: isHidden || false, 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 { success: true, diff --git a/server/api/profile/notifications.get.js b/server/api/profile/notifications.get.js new file mode 100644 index 0000000..8aeff9f --- /dev/null +++ b/server/api/profile/notifications.get.js @@ -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) + } +}) diff --git a/server/api/profile/notifications.put.js b/server/api/profile/notifications.put.js new file mode 100644 index 0000000..0e4fd40 --- /dev/null +++ b/server/api/profile/notifications.put.js @@ -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 + } +}) diff --git a/server/api/profile/push-token.post.js b/server/api/profile/push-token.post.js new file mode 100644 index 0000000..fe8c57d --- /dev/null +++ b/server/api/profile/push-token.post.js @@ -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.' } +}) diff --git a/server/utils/auth.js b/server/utils/auth.js index 5a9c0fd..c5fec68 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -4,6 +4,7 @@ import crypto from 'crypto' import { promises as fs } from 'fs' import path from 'path' import { encryptObject, decryptObject } from './encryption.js' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Export migrateUserRoles für Verwendung in anderen Modulen export function migrateUserRoles(user) { @@ -26,6 +27,28 @@ export function migrateUserRoles(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' // Handle both dev and production paths @@ -196,7 +219,7 @@ export async function writeUsers(users) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(users, encryptionKey) - await fs.writeFile(USERS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Benutzerdaten:', error) @@ -262,7 +285,7 @@ export async function writeSessions(sessions) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(sessions, encryptionKey) - await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Sessions:', error) diff --git a/server/utils/contact-requests.js b/server/utils/contact-requests.js index a3a034a..39d7465 100644 --- a/server/utils/contact-requests.js +++ b/server/utils/contact-requests.js @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' 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 // filename is always a hardcoded constant, never user input @@ -29,7 +30,7 @@ export async function readContactRequests() { } 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) { diff --git a/server/utils/data-file-rotation.js b/server/utils/data-file-rotation.js new file mode 100644 index 0000000..2cc6fa0 --- /dev/null +++ b/server/utils/data-file-rotation.js @@ -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) +} diff --git a/server/utils/members.js b/server/utils/members.js index 444e240..4e0ea25 100644 --- a/server/utils/members.js +++ b/server/utils/members.js @@ -2,6 +2,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Handle both dev and production paths // 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 { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(members, encryptionKey) - await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Mitgliederdaten:', error) diff --git a/server/utils/news.js b/server/utils/news.js index c0d13c2..58c9e84 100644 --- a/server/utils/news.js +++ b/server/utils/news.js @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Handle both dev and production paths // 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 export async function writeNews(news) { 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 } catch (error) { console.error('Fehler beim Schreiben der News:', error) diff --git a/server/utils/newsletter.js b/server/utils/newsletter.js index 68a9441..d76e815 100644 --- a/server/utils/newsletter.js +++ b/server/utils/newsletter.js @@ -1,9 +1,10 @@ import fs from 'fs/promises' import path from 'path' import { readMembers } from './members.js' -import { readUsers } from './auth.js' +import { readUsers, isHiddenUser, normalizeUserEmail } from './auth.js' import { encryptObject, decryptObject } from './encryption.js' 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 // filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input @@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) { try { const encryptionKey = getEncryptionKey() 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 } catch (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 -function filterAdminUser(recipients) { +// Filtert interne System-/Hidden-Accounts aus Empfängerliste heraus +function filterInternalUsers(recipients, hiddenEmails = new Set()) { return recipients.filter(r => { - const email = (r.email || '').toLowerCase().trim() - return email !== 'admin@harheimertc.de' + const email = normalizeUserEmail(r.email) + return email && email !== 'admin@harheimertc.de' && !hiddenEmails.has(email) }) } @@ -173,20 +174,26 @@ function filterAdminUser(recipients) { export async function getRecipientsByGroup(targetGroup) { const members = await readMembers() 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 = [] switch (targetGroup) { case 'alle': // Alle Mitglieder mit E-Mail - recipients = members + recipients = visibleMembers .filter(m => m.email && m.email.trim() !== '') .map(m => ({ email: m.email, name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) // Auch alle aktiven Benutzer hinzufügen - users + visibleUsers .filter(u => u.active && u.email) .forEach(u => { if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) { @@ -200,7 +207,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'erwachsene': // Mitglieder über 18 Jahre - recipients = members + recipients = visibleMembers .filter(m => { if (!m.email || !m.email.trim()) return false const age = calculateAge(m.geburtsdatum) @@ -211,7 +218,7 @@ export async function getRecipientsByGroup(targetGroup) { name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) // Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum) - users + visibleUsers .filter(u => u.active && u.email && u.email.trim()) .forEach(u => { // Prüfe ob bereits vorhanden @@ -226,7 +233,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'nachwuchs': // Mitglieder unter 18 Jahre - recipients = members + recipients = visibleMembers .filter(m => { if (!m.email || !m.email.trim()) return false const age = calculateAge(m.geburtsdatum) @@ -238,7 +245,7 @@ export async function getRecipientsByGroup(targetGroup) { })) // Zusätzlich aktive Trainer aus users.json anschreiben - users + visibleUsers .filter(u => { if (!u.active || !u.email || !u.email.trim()) return false const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) @@ -256,7 +263,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'mannschaftsspieler': // Mitglieder die in einer Mannschaft spielen - recipients = members + recipients = visibleMembers .filter(m => { if (!m.email || !m.email.trim()) return false // Prüfe ob als Mannschaftsspieler markiert @@ -275,7 +282,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'vorstand': // Nur Vorstand (aus users.json) - recipients = users + recipients = visibleUsers .filter(u => { if (!u.active || !u.email) return false const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) @@ -292,12 +299,13 @@ export async function getRecipientsByGroup(targetGroup) { } // Admin-User herausfiltern - return filterAdminUser(recipients) + return filterInternalUsers(recipients, hiddenUserEmails) } // Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet) export async function getNewsletterSubscribers(internalOnly = false, groupId = null) { const subscribers = await readSubscribers() + const hiddenUserEmails = new Set((await readUsers()).filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean)) let confirmedSubscribers = subscribers.filter(s => { if (!s.confirmed || s.unsubscribedAt) { @@ -328,12 +336,12 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n const members = await readMembers() const memberEmails = new Set( members - .filter(m => m.email) - .map(m => m.email.toLowerCase()) + .filter(m => m.email && m.hidden !== true && m.invisible !== true && m.isHidden !== true && !hiddenUserEmails.has(normalizeUserEmail(m.email))) + .map(m => normalizeUserEmail(m.email)) ) 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 - return filterAdminUser(result) + return filterInternalUsers(result, hiddenUserEmails) } // Generiert Abmelde-Token für Abonnenten diff --git a/server/utils/notification-settings.js b/server/utils/notification-settings.js new file mode 100644 index 0000000..6d98feb --- /dev/null +++ b/server/utils/notification-settings.js @@ -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 || {}) + }) +} diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js new file mode 100644 index 0000000..a900563 --- /dev/null +++ b/server/utils/push-notifications.js @@ -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 } +} diff --git a/server/utils/termine.js b/server/utils/termine.js index b6a7df2..c879c20 100644 --- a/server/utils/termine.js +++ b/server/utils/termine.js @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Use internal server/data directory for Termine CSV to avoid writing to public/ const getDataPath = (filename) => { @@ -89,7 +90,7 @@ export async function writeTermine(termine) { 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 } catch (error) { console.error('Fehler beim Schreiben der Termine:', error) diff --git a/temp/device-35433-after-intern-fix.png b/temp/device-35433-after-intern-fix.png new file mode 100644 index 0000000..7da1175 Binary files /dev/null and b/temp/device-35433-after-intern-fix.png differ diff --git a/temp/device-35433-cms-after-split-2.png b/temp/device-35433-cms-after-split-2.png new file mode 100644 index 0000000..3d9d41e Binary files /dev/null and b/temp/device-35433-cms-after-split-2.png differ diff --git a/temp/device-35433-cms-after-split.png b/temp/device-35433-cms-after-split.png new file mode 100644 index 0000000..97b86ce Binary files /dev/null and b/temp/device-35433-cms-after-split.png differ diff --git a/temp/device-35433-cms-open.png b/temp/device-35433-cms-open.png new file mode 100644 index 0000000..829703b Binary files /dev/null and b/temp/device-35433-cms-open.png differ diff --git a/temp/device-35433-home.png b/temp/device-35433-home.png new file mode 100644 index 0000000..ba3aef7 Binary files /dev/null and b/temp/device-35433-home.png differ diff --git a/temp/device-35433-intern-after-split.png b/temp/device-35433-intern-after-split.png new file mode 100644 index 0000000..aa849f3 Binary files /dev/null and b/temp/device-35433-intern-after-split.png differ diff --git a/temp/device-37165-arrow-check.png b/temp/device-37165-arrow-check.png new file mode 100644 index 0000000..e69de29 diff --git a/temp/device-38281-after-fix.png b/temp/device-38281-after-fix.png new file mode 100644 index 0000000..793954d Binary files /dev/null and b/temp/device-38281-after-fix.png differ diff --git a/temp/device-38281-cms-2.png b/temp/device-38281-cms-2.png new file mode 100644 index 0000000..793954d Binary files /dev/null and b/temp/device-38281-cms-2.png differ diff --git a/temp/device-38281-cms-open.png b/temp/device-38281-cms-open.png new file mode 100644 index 0000000..2226db3 Binary files /dev/null and b/temp/device-38281-cms-open.png differ diff --git a/temp/device-38281-cms.png b/temp/device-38281-cms.png new file mode 100644 index 0000000..793954d Binary files /dev/null and b/temp/device-38281-cms.png differ diff --git a/temp/device-38281.png b/temp/device-38281.png new file mode 100644 index 0000000..97993c1 Binary files /dev/null and b/temp/device-38281.png differ diff --git a/temp/device-43477-app-after-start.png b/temp/device-43477-app-after-start.png new file mode 100644 index 0000000..caa792e Binary files /dev/null and b/temp/device-43477-app-after-start.png differ diff --git a/temp/device-43477-arrow-check.png b/temp/device-43477-arrow-check.png new file mode 100644 index 0000000..3c52bb2 Binary files /dev/null and b/temp/device-43477-arrow-check.png differ diff --git a/temp/device-43477-intern-inline.png b/temp/device-43477-intern-inline.png new file mode 100644 index 0000000..9a2145e Binary files /dev/null and b/temp/device-43477-intern-inline.png differ diff --git a/temp/device-43477-mannschaften-home.png b/temp/device-43477-mannschaften-home.png new file mode 100644 index 0000000..e6ab93f Binary files /dev/null and b/temp/device-43477-mannschaften-home.png differ diff --git a/temp/ui-35433.xml b/temp/ui-35433.xml new file mode 100644 index 0000000..d9249c6 --- /dev/null +++ b/temp/ui-35433.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/temp/ui-cms.xml b/temp/ui-cms.xml new file mode 100644 index 0000000..d9249c6 --- /dev/null +++ b/temp/ui-cms.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/temp/ui-current.xml b/temp/ui-current.xml new file mode 100644 index 0000000..117bf9a --- /dev/null +++ b/temp/ui-current.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/temp/ui.xml b/temp/ui.xml new file mode 100644 index 0000000..d9249c6 --- /dev/null +++ b/temp/ui.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/auth-endpoints.spec.ts b/tests/auth-endpoints.spec.ts index 2782d6b..2377f18 100644 --- a/tests/auth-endpoints.spec.ts +++ b/tests/auth-endpoints.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createEvent, mockSuccessReadBody } from './setup' import { readFileSync } from 'fs' +import crypto from 'crypto' vi.mock('../server/utils/auth.js', () => { return { @@ -74,6 +75,7 @@ import logoutHandler from '../server/api/auth/logout.post.js' import refreshHandler from '../server/api/auth/refresh.post.js' import registerHandler from '../server/api/auth/register.post.js' import resetPasswordHandler from '../server/api/auth/reset-password.post.js' +import completePasswordResetHandler from '../server/api/auth/reset-password/complete.post.js' import statusHandler from '../server/api/auth/status.get.js' import versionHandler from '../server/api/app/version.get.js' @@ -81,12 +83,15 @@ describe('Auth API Endpoints', () => { afterEach(() => { delete process.env.NODE_ENV delete process.env.APP_ENV + delete process.env.NUXT_PUBLIC_BASE_URL + delete process.env.PASSWORD_RESET_TTL_MIN }) beforeEach(() => { // Setze SMTP-Credentials für Tests process.env.SMTP_USER = 'test@example.com' process.env.SMTP_PASS = 'test-password' + process.env.NUXT_PUBLIC_BASE_URL = 'https://harheimertc.de' vi.clearAllMocks() }) @@ -300,7 +305,7 @@ describe('Auth API Endpoints', () => { }) describe('POST /api/auth/reset-password', () => { - it('prüft Pflichtfelder', async () => { + it('prüft Pflichtfelder ohne öffentliche Fehlermeldung', async () => { const event = createEvent() mockSuccessReadBody({}) @@ -308,18 +313,27 @@ describe('Auth API Endpoints', () => { expect(response.success).toBe(true) }) - it('aktualisiert Passwort bei vorhandenem Benutzer', async () => { + it('speichert einen gehashten Reset-Token und lässt das alte Passwort unverändert', async () => { const event = createEvent() - const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } + const user = { id: '1', email: 'user@example.com', name: 'User', password: 'old-hash' } mockSuccessReadBody({ email: user.email }) authUtils.readUsers.mockResolvedValue([user]) - authUtils.hashPassword.mockResolvedValue('new-hash') authUtils.writeUsers.mockResolvedValue(true) const response = await resetPasswordHandler(event) + expect(response.success).toBe(true) - expect(authUtils.writeUsers).toHaveBeenCalled() - expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset') + expect(authUtils.hashPassword).not.toHaveBeenCalled() + expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled() + const writtenUser = authUtils.writeUsers.mock.calls[0][0][0] + expect(writtenUser.password).toBe('old-hash') + expect(writtenUser.passwordResetTokens).toHaveLength(1) + expect(writtenUser.passwordResetTokens[0]).toMatchObject({ usedAt: null }) + expect(writtenUser.passwordResetTokens[0].tokenHash).toMatch(/^[a-f0-9]{64}$/) + const transporter = nodemailer.default.createTransport.mock.results[0].value + expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + html: expect.stringContaining('https://harheimertc.de/passwort-zuruecksetzen?token=') + })) expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com', step: 'mail_send', @@ -332,7 +346,6 @@ describe('Auth API Endpoints', () => { const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } mockSuccessReadBody({ email: ' User@Example.com ' }) authUtils.readUsers.mockResolvedValue([user]) - authUtils.hashPassword.mockResolvedValue('new-hash') authUtils.writeUsers.mockResolvedValue(true) await resetPasswordHandler(event) @@ -341,11 +354,10 @@ describe('Auth API Endpoints', () => { expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ') }) - it('ändert das Passwort nicht, wenn SMTP nicht konfiguriert ist', async () => { + it('ändert nichts, wenn SMTP nicht konfiguriert ist', async () => { const event = createEvent() mockSuccessReadBody({ email: 'user@example.com' }) authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }]) - authUtils.hashPassword.mockResolvedValue('new-hash') delete process.env.SMTP_USER delete process.env.SMTP_PASS @@ -360,11 +372,12 @@ describe('Auth API Endpoints', () => { })) }) - it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', async () => { + it('protokolliert einen Mailfehler ohne das Passwort zu ersetzen', async () => { const event = createEvent() + const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } mockSuccessReadBody({ email: 'user@example.com' }) - authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }]) - authUtils.hashPassword.mockResolvedValue('new-hash') + authUtils.readUsers.mockResolvedValue([user]) + authUtils.writeUsers.mockResolvedValue(true) nodemailer.default.createTransport.mockReturnValueOnce({ sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' })) }) @@ -372,7 +385,8 @@ describe('Auth API Endpoints', () => { const response = await resetPasswordHandler(event) expect(response.success).toBe(true) - expect(authUtils.writeUsers).not.toHaveBeenCalled() + expect(authUtils.hashPassword).not.toHaveBeenCalled() + expect(authUtils.writeUsers.mock.calls[0][0][0].password).toBe('hash') expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ step: 'mail_send', status: 'failed' @@ -380,6 +394,61 @@ describe('Auth API Endpoints', () => { }) }) + describe('POST /api/auth/reset-password/complete', () => { + it('setzt ein neues Passwort mit gültigem Reset-Token', async () => { + const token = 'reset-token' + const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest('hex') + const event = createEvent() + const user = { + id: '1', + email: 'user@example.com', + password: 'old-hash', + passwordResetRequired: true, + passwordResetTokens: [{ tokenHash, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60000).toISOString(), usedAt: null }] + } + mockSuccessReadBody({ token, password: 'new-password' }) + authUtils.readUsers.mockResolvedValue([user]) + authUtils.hashPassword.mockResolvedValue('new-hash') + authUtils.writeUsers.mockResolvedValue(true) + + const response = await completePasswordResetHandler(event) + + expect(response.success).toBe(true) + expect(authUtils.hashPassword).toHaveBeenCalledWith('new-password') + expect(authUtils.writeUsers.mock.calls[0][0][0]).toMatchObject({ + password: 'new-hash', + passwordResetRequired: false + }) + expect(authUtils.writeUsers.mock.calls[0][0][0].passwordResetTokens[0].usedAt).toEqual(expect.any(String)) + expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset_completed') + }) + + it('weist abgelaufene Reset-Tokens zurück', async () => { + const token = 'reset-token' + const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest('hex') + const event = createEvent() + mockSuccessReadBody({ token, password: 'new-password' }) + authUtils.readUsers.mockResolvedValue([{ + id: '1', + email: 'user@example.com', + password: 'old-hash', + passwordResetTokens: [{ tokenHash, expiresAt: new Date(Date.now() - 60000).toISOString(), usedAt: null }] + }]) + + await expect(completePasswordResetHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + expect(authUtils.writeUsers).not.toHaveBeenCalled() + expect(authUtils.hashPassword).not.toHaveBeenCalled() + }) + + it('verlangt mindestens acht Zeichen für das neue Passwort', async () => { + const event = createEvent() + mockSuccessReadBody({ token: 'reset-token', password: 'kurz' }) + + await expect(completePasswordResetHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + expect(authUtils.readUsers).not.toHaveBeenCalled() + }) + }) + describe('GET /api/auth/status', () => { it('liefert loggedOut, wenn kein Cookie gesetzt ist', async () => { const event = createEvent() diff --git a/tests/cms-users-endpoints.spec.ts b/tests/cms-users-endpoints.spec.ts index ef1cbbd..0f65645 100644 --- a/tests/cms-users-endpoints.spec.ts +++ b/tests/cms-users-endpoints.spec.ts @@ -26,7 +26,8 @@ vi.mock('../server/utils/auth.js', () => ({ user.roles = ['mitglied'] } return user - }) + }), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') })) vi.mock('nodemailer', () => { @@ -96,6 +97,20 @@ describe('CMS User Management Endpoints', () => { expect(response.users[0]).not.toHaveProperty('password') expect(response.users).toHaveLength(1) }) + + + it('blendet unsichtbare Playstore-Benutzer auch für Admins aus', async () => { + const event = adminEvent() + authUtils.readUsers.mockResolvedValue([ + { id: '1', email: 'a@b.de', name: 'Anna', roles: ['mitglied'], active: true }, + { id: '2', email: 'review@club.de', name: 'Playstore Review', roles: ['mitglied'], active: true, accountType: 'playstore_review' } + ]) + + const response = await usersListHandler(event) + + expect(response.users).toHaveLength(1) + expect(response.users[0].email).toBe('a@b.de') + }) }) describe('POST /api/cms/users/approve', () => { @@ -239,5 +254,31 @@ describe('CMS User Management Endpoints', () => { expect(response.attempts).toHaveLength(1) expect(response.attempts[0]).toMatchObject({ requestId: 'r1', failed: true }) }) + + + it('blendet unsichtbare Benutzer und ihre Reset-Logs in der Diagnose aus', async () => { + const event = adminEvent() + event.__query = { email: 'review@club.de', failedOnly: 'false' } + authUtils.hasRole.mockReturnValue(true) + authUtils.readUsers.mockResolvedValue([ + { id: '2', email: 'review@club.de', name: 'Playstore Review', active: true, accountType: 'playstore_review' } + ]) + passwordResetLog.readPasswordResetLogs.mockResolvedValue([ + { + requestId: 'r-hidden', + ts: '2026-05-27T10:00:01.000Z', + emailMasked: 're***@cl***.de', + emailFingerprint: 'fingerprint:review@club.de', + ip: '127.0.0.1', + step: 'request_completed', + status: 'failed' + } + ]) + + const response = await passwordResetDiagnosticsHandler(event) + + expect(response.matchingUsers).toHaveLength(0) + expect(response.attempts).toHaveLength(0) + }) }) }) diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts index 57dc6fd..3d3e9bb 100644 --- a/tests/config-profile-endpoints.spec.ts +++ b/tests/config-profile-endpoints.spec.ts @@ -35,6 +35,9 @@ import configGetHandler from '../server/api/config.get.js' import configPutHandler from '../server/api/config.put.js' import profileGetHandler from '../server/api/profile.get.js' import profilePutHandler from '../server/api/profile.put.js' +import profileNotificationsGetHandler from '../server/api/profile/notifications.get.js' +import profileNotificationsPutHandler from '../server/api/profile/notifications.put.js' +import profilePushTokenHandler from '../server/api/profile/push-token.post.js' const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-') const validCurrentPassword = ['valid', 'test', 'pw'].join('-') @@ -157,6 +160,113 @@ describe('Config & Profil Endpoints', () => { }) }) + + describe('GET /api/profile/notifications', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + + await expect(profileNotificationsGetHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('liefert Defaults plus gespeicherte Benachrichtigungseinstellungen', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue({ + id: '1', + notificationSettings: { + eventsToday: true, + selectedTeamSlugs: ['herren-1', 'herren-1', ''], + selectedTeamSeason: '2025/2026', + notificationTime: '07:30' + } + }) + + const result = await profileNotificationsGetHandler(event) + + expect(result.success).toBe(true) + expect(result.settings.eventsToday).toBe(true) + expect(result.settings.newEvents).toBe(false) + expect(result.settings.selectedTeamSlugs).toEqual(['herren-1']) + expect(result.settings.notificationTime).toBe('07:30') + }) + }) + + describe('PUT /api/profile/notifications', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent({ body: { eventsToday: true } }) + + await expect(profileNotificationsPutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('speichert sanitizte Benachrichtigungseinstellungen am Benutzer', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + mockSuccessReadBody({ + newEvents: true, + eventsToday: 'true', + birthdays: true, + selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '], + selectedTeamSeason: '2026/2027', + notificationTime: '25:99' + }) + const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }] + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue(users[0]) + authUtils.readUsers.mockResolvedValue(users) + authUtils.writeUsers.mockResolvedValue(true) + + const result = await profileNotificationsPutHandler(event) + + expect(result.success).toBe(true) + expect(result.settings.newEvents).toBe(true) + expect(result.settings.eventsToday).toBe(false) + expect(result.settings.birthdays).toBe(true) + expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend']) + expect(result.settings.notificationTime).toBe('09:00') + expect(authUtils.writeUsers).toHaveBeenCalledWith([ + expect.objectContaining({ + id: '1', + notificationSettings: expect.objectContaining({ + newEvents: true, + birthdays: true, + selectedTeamSeason: '2026/2027' + }) + }) + ]) + }) + }) + + + + describe('POST /api/profile/push-token', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + mockSuccessReadBody({ token: 'fcm-token' }) + + await expect(profilePushTokenHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('speichert Android-Push-Token am Benutzer', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' }) + const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }] + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue(users[0]) + authUtils.readUsers.mockResolvedValue(users) + authUtils.writeUsers.mockResolvedValue(true) + + const result = await profilePushTokenHandler(event) + + expect(result.success).toBe(true) + expect(authUtils.writeUsers).toHaveBeenCalledWith([ + expect.objectContaining({ + id: '1', + pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })] + }) + ]) + }) + }) + + describe('PUT /api/profile', () => { it('verlangt Authentifizierung', async () => { const event = createEvent() diff --git a/tests/data-file-rotation.spec.ts b/tests/data-file-rotation.spec.ts new file mode 100644 index 0000000..27b0086 --- /dev/null +++ b/tests/data-file-rotation.spec.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import os from 'os' +import path from 'path' +import { promises as fs } from 'fs' +import { + getBackupDirectoryForDataFile, + resolveDataFileBackupPath, + listDataFileBackups, + restoreDataFileBackup, + writeDataFileWithRotation +} from '../server/utils/data-file-rotation.js' + +describe('Data file rotation utility', () => { + let tempRoot = '' + let backupRoot = '' + let previousBackupDir = '' + let dataFile = '' + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harheimertc-data-rotation-')) + backupRoot = path.join(tempRoot, 'backup-store') + dataFile = path.join(tempRoot, 'server', 'data', 'users.json') + + previousBackupDir = process.env.DATA_FILE_BACKUP_DIR || '' + process.env.DATA_FILE_BACKUP_DIR = backupRoot + }) + + afterEach(async () => { + if (previousBackupDir) { + process.env.DATA_FILE_BACKUP_DIR = previousBackupDir + } else { + delete process.env.DATA_FILE_BACKUP_DIR + } + + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('writes initial file without creating backup', async () => { + const result = await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 }) + + expect(result.changed).toBe(true) + expect(result.backupPath).toBe(null) + + const content = await fs.readFile(dataFile, 'utf-8') + expect(content).toBe('v1') + + const backups = await listDataFileBackups(dataFile) + expect(backups).toEqual([]) + }) + + it('creates backup from previous content on change', async () => { + await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 }) + const result = await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 5 }) + + expect(result.changed).toBe(true) + expect(result.backupPath).toBeTruthy() + + const backups = await listDataFileBackups(dataFile) + expect(backups.length).toBe(1) + + const backupDir = getBackupDirectoryForDataFile(dataFile) + const backupContent = await fs.readFile(resolveDataFileBackupPath(backupDir, backups[0]), 'utf-8') + expect(backupContent).toBe('v1') + + const currentContent = await fs.readFile(dataFile, 'utf-8') + expect(currentContent).toBe('v2') + }) + + it('does not create backup when content is unchanged', async () => { + await writeDataFileWithRotation(dataFile, 'stable', { maxBackups: 5 }) + await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 }) + + const before = await listDataFileBackups(dataFile) + const result = await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 }) + const after = await listDataFileBackups(dataFile) + + expect(result.changed).toBe(false) + expect(result.backupPath).toBe(null) + expect(after).toEqual(before) + }) + + it('rotates old backups and keeps configured maximum', async () => { + await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 2 }) + await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 2 }) + await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 2 }) + await writeDataFileWithRotation(dataFile, 'v4', { maxBackups: 2 }) + + const backups = await listDataFileBackups(dataFile) + expect(backups.length).toBe(2) + + const backupDir = getBackupDirectoryForDataFile(dataFile) + const backupContents = await Promise.all( + backups.map((name) => fs.readFile(resolveDataFileBackupPath(backupDir, name), 'utf-8')) + ) + + expect(backupContents).toEqual(expect.arrayContaining(['v2', 'v3'])) + expect(backupContents).not.toContain('v1') + }) + + it('restores selected backup and keeps pre-restore state as backup', async () => { + await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 10 }) + await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 10 }) + await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 10 }) + + const beforeRestoreBackups = await listDataFileBackups(dataFile) + const backupDir = getBackupDirectoryForDataFile(dataFile) + + const resolved = await Promise.all( + beforeRestoreBackups.map(async (name) => ({ + name, + content: await fs.readFile(resolveDataFileBackupPath(backupDir, name), 'utf-8') + })) + ) + const v1Entry = resolved.find((entry) => entry.content === 'v1') + expect(v1Entry).toBeTruthy() + + const restoreResult = await restoreDataFileBackup(dataFile, v1Entry!.name, { maxBackups: 10 }) + expect(restoreResult.changed).toBe(true) + expect(restoreResult.backupPath).toBeTruthy() + + const restoredContent = await fs.readFile(dataFile, 'utf-8') + expect(restoredContent).toBe('v1') + + const afterRestoreBackups = await listDataFileBackups(dataFile) + const afterContents = await Promise.all( + afterRestoreBackups.map((name) => fs.readFile(resolveDataFileBackupPath(backupDir, name), 'utf-8')) + ) + + expect(afterContents).toContain('v3') + }) +}) diff --git a/tests/members-endpoints.spec.ts b/tests/members-endpoints.spec.ts index 8e69eeb..6ec1c9e 100644 --- a/tests/members-endpoints.spec.ts +++ b/tests/members-endpoints.spec.ts @@ -31,7 +31,9 @@ vi.mock('../server/utils/auth.js', () => ({ if (!user) return false const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return userRoles.includes(role) - }) + }), + normalizeUserEmail: vi.fn(email => String(email || '').trim().toLowerCase()), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') })) vi.mock('../server/utils/members.js', () => ({ @@ -97,6 +99,28 @@ describe('Members API Endpoints', () => { expect(response.members).toHaveLength(1) expect(response.members[0].name).toBe('Anna Muster') }) + + + it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + memberUtils.readMembers.mockResolvedValue([ + { id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de' }, + { id: 'm2', firstName: 'Play', lastName: 'Store', email: 'review@club.de' } + ]) + authUtils.readUsers.mockResolvedValue([ + { id: 'u1', name: 'Ben Nutzer', email: 'ben@club.de', role: 'mitglied', active: true }, + { id: 'u2', name: 'Playstore Review', email: 'review@club.de', roles: ['mitglied'], active: true, accountType: 'playstore_review' } + ]) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied' }) + + const response = await membersGetHandler(event) + + expect(response.members.map(member => member.email || member.name)).not.toContain('review@club.de') + expect(response.members.map(member => member.name)).not.toContain('Play Store') + expect(response.members.map(member => member.name)).not.toContain('Playstore Review') + expect(response.members).toHaveLength(2) + }) }) describe('POST /api/members', () => { diff --git a/tests/news-endpoints.spec.ts b/tests/news-endpoints.spec.ts index 67216e0..7f8b8ff 100644 --- a/tests/news-endpoints.spec.ts +++ b/tests/news-endpoints.spec.ts @@ -17,8 +17,13 @@ vi.mock('../server/utils/news.js', () => ({ deleteNews: vi.fn() })) +vi.mock('../server/utils/push-notifications.js', () => ({ + sendNewNewsPush: vi.fn().mockResolvedValue({ sent: 1, skipped: false }) +})) + const authUtils = await import('../server/utils/auth.js') const newsUtils = await import('../server/utils/news.js') +const pushUtils = await import('../server/utils/push-notifications.js') import newsGetHandler from '../server/api/news.get.js' import newsPostHandler from '../server/api/news.post.js' @@ -111,6 +116,29 @@ describe('News API Endpoints', () => { expect(newsUtils.saveNews).toHaveBeenCalledWith( expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true }) ) + expect(pushUtils.sendNewNewsPush).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true }) + ) + }) + + it('sendet keinen Push bei News-Update', async () => { + const event = adminEvent() + newsUtils.saveNews.mockResolvedValue(undefined) + mockSuccessReadBody({ id: 'existing-news', title: 'Update', content: 'Inhalt' }) + + await newsPostHandler(event) + + expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled() + }) + + it('sendet keinen Push bei versteckten News', async () => { + const event = adminEvent() + newsUtils.saveNews.mockResolvedValue(undefined) + mockSuccessReadBody({ title: 'Intern', content: 'Inhalt', isHidden: true }) + + await newsPostHandler(event) + + expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled() }) it('setzt autor auf den angemeldeten Benutzer', async () => { diff --git a/tests/spielplan-public-endpoints.spec.ts b/tests/spielplan-public-endpoints.spec.ts index 7cff8d9..b588d8a 100644 --- a/tests/spielplan-public-endpoints.spec.ts +++ b/tests/spielplan-public-endpoints.spec.ts @@ -19,7 +19,9 @@ vi.mock('../server/utils/auth.js', () => ({ verifyToken: vi.fn(), getUserFromToken: vi.fn(), readUsers: vi.fn().mockResolvedValue([]), - migrateUserRoles: vi.fn(user => user) + migrateUserRoles: vi.fn(user => user), + normalizeUserEmail: vi.fn(email => String(email || '').trim().toLowerCase()), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') })) vi.mock('../server/utils/members.js', () => ({ @@ -264,5 +266,27 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => { expect(result.birthdays).toHaveLength(0) }) + + + it('blendet unsichtbare Playstore-Benutzer auch für Vorstand aus', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + const inDays = 7 + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + inDays) + const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}` + + authUtils.verifyToken.mockReturnValue({ id: 'v1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: 'v1', roles: ['vorstand'], active: true }) + authUtils.readUsers.mockResolvedValue([ + { id: 'u2', name: 'Playstore Review', email: 'review@club.de', active: true, geburtsdatum, accountType: 'playstore_review' } + ]) + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Play', lastName: 'Store', email: 'review@club.de', geburtsdatum, visibility: { showBirthday: true } } + ]) + + const result = await birthdaysHandler(event) + + expect(result.birthdays).toHaveLength(0) + }) }) })