Merge pull request 'dev' (#41) from dev into main
Reviewed-on: #41
@@ -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'"
|
||||
|
||||
3
.gitleaksignore
Normal file
@@ -0,0 +1,3 @@
|
||||
google-services.json:gcp-api-key:18
|
||||
google-services.json:gcp-api-key:37
|
||||
google-services.json:gcp-api-key:56
|
||||
@@ -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")
|
||||
|
||||
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../../google-services.json
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
@@ -10,12 +11,20 @@
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".notifications.HarheimerMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
|
||||
@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
HarheimerNotifications.createChannels(this)
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN
|
||||
|
||||
@@ -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<String?>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ data class SpielplanResponse(
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
val seasons: List<String> = emptyList(),
|
||||
val currentSeason: String = "",
|
||||
val defaultSeason: String = "",
|
||||
)
|
||||
data class SpielDto(
|
||||
@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<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
)
|
||||
data class NotificationSettingsResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val settings: NotificationSettingsDto = NotificationSettingsDto(),
|
||||
)
|
||||
data class PushTokenRequest(
|
||||
val token: String,
|
||||
val platform: String = "android",
|
||||
val appVersion: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
@@ -584,6 +614,9 @@ interface ApiService {
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/mannschaften/seasons")
|
||||
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@@ -651,6 +684,15 @@ interface ApiService {
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||
|
||||
@GET("/api/profile/notifications")
|
||||
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile/notifications")
|
||||
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
|
||||
|
||||
@POST("/api/profile/push-token")
|
||||
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConnectivityMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _online = MutableStateFlow(hasInternetAccess())
|
||||
val online: StateFlow<Boolean> = _online.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { poll() }
|
||||
}
|
||||
|
||||
private suspend fun poll() {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val current = hasInternetAccess()
|
||||
if (_online.value != current) {
|
||||
_online.value = current
|
||||
}
|
||||
delay(10_000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasInternetAccess(): Boolean {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return false
|
||||
val network = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.harheimertc.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.harheimertc.MainActivity
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
|
||||
object HarheimerNotifications {
|
||||
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
DEFAULT_CHANNEL_ID,
|
||||
"Harheimer TC",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Benachrichtigungen des Harheimer TC"
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun showBasicNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
title: String,
|
||||
message: String,
|
||||
data: Map<String, String> = emptyMap(),
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission(context)) return false
|
||||
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||
val route = destinationRoute(payload)
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
|
||||
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
|
||||
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||
"news" -> Destinations.MemberNews.route
|
||||
else -> Destinations.Home.route
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = 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<GalleryPage> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<List<HomepageSectionDto>>(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<HomepageSectionDto>? {
|
||||
|
||||
@@ -31,19 +31,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
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<SpielplanResponse> = 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")
|
||||
|
||||
@@ -23,7 +23,7 @@ class LoginRepository @Inject constructor(
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = 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<AuthMessageResponse> = 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<AuthMessageResponse> = 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? {
|
||||
|
||||
@@ -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<List<Mannschaft>> = 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<MannschaftenSeasonsResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaftenSeasons()
|
||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal suspend fun <T> retryOnNetworkFailure(
|
||||
retryDelayMillis: Long = 10_000L,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
if (!error.isRetryableNetworkError()) throw error
|
||||
delay(retryDelayMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
|
||||
is UnknownHostException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException,
|
||||
is SSLException -> true
|
||||
else -> false
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import javax.inject.Inject
|
||||
|
||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun groups(): Result<NewsletterGroupsResponse> = 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<AuthMessageResponse> = runCatching {
|
||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
}
|
||||
|
||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.NotificationSettingsDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
|
||||
|
||||
data class NotificationPreferences(
|
||||
val newNews: Boolean = false,
|
||||
val newEvents: Boolean = false,
|
||||
val eventsToday: Boolean = false,
|
||||
val eventsTomorrow: Boolean = false,
|
||||
val ownTeamMatches: Boolean = false,
|
||||
val allTeamMatches: Boolean = false,
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class NotificationPreferencesRepository @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiService,
|
||||
) {
|
||||
private val preferences by lazy {
|
||||
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun loadLocal(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
|
||||
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
|
||||
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
|
||||
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
|
||||
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
|
||||
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
|
||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||
)
|
||||
|
||||
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.notificationSettings()
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
|
||||
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
saveLocal(settings)
|
||||
settings
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocal(settings: NotificationPreferences) {
|
||||
preferences.edit()
|
||||
.putBoolean(KEY_NEW_NEWS, settings.newNews)
|
||||
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
|
||||
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
|
||||
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
|
||||
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
|
||||
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
|
||||
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||
.apply()
|
||||
}
|
||||
|
||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||
saveLocal(settings)
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateNotificationSettings(settings.toDto())
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
saveLocal(saved)
|
||||
saved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_NEW_NEWS = "new_news"
|
||||
const val KEY_NEW_EVENTS = "new_events"
|
||||
const val KEY_EVENTS_TODAY = "events_today"
|
||||
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
|
||||
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
|
||||
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
eventsToday = eventsToday,
|
||||
eventsTomorrow = eventsTomorrow,
|
||||
ownTeamMatches = ownTeamMatches,
|
||||
allTeamMatches = allTeamMatches,
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
)
|
||||
|
||||
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
eventsToday = eventsToday,
|
||||
eventsTomorrow = eventsTomorrow,
|
||||
ownTeamMatches = ownTeamMatches,
|
||||
allTeamMatches = allTeamMatches,
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
)
|
||||
@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = 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<PasskeysResponse> = 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<AuthMessageResponse> = 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<AuthMessageResponse> = runCatching {
|
||||
|
||||
@@ -9,14 +9,18 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun load(): Result<ProfileResponse> = 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<ProfileResponse> = 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,43 +31,49 @@ data class MeisterschaftResult(
|
||||
@Singleton
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = 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<List<Spielsystem>> = 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<List<MeisterschaftResult>> = 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) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.PushTokenRequest
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PushTokenRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
) {
|
||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
registerToken(token).getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun registerToken(token: String): Result<Unit> = runCatching {
|
||||
if (token.isBlank()) return@runCatching
|
||||
retryOnNetworkFailure {
|
||||
val response = api.registerPushToken(
|
||||
PushTokenRequest(
|
||||
token = token,
|
||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,22 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = 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<TeamTableResponse> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchTermine(): Result<List<TerminDto>> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MenuSection?>(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<MenuTarget>()
|
||||
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<MenuSection?>(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<MenuTarget>()
|
||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
||||
cmsExpanded.value = true
|
||||
}
|
||||
// First row: render all subitems but do NOT render CMS children here
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
subItems.forEachIndexed { idx, item ->
|
||||
if (idx == cmsIndex) {
|
||||
// CMS parent toggle
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
cmsExpanded.value = !cmsExpanded.value
|
||||
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<MenuT
|
||||
MenuTarget("Links", Destinations.Links.route),
|
||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||
)
|
||||
|
||||
MenuSection.MANNSCHAFTEN -> 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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> = 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<NavigationUiState> = _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<String> = buildSet {
|
||||
addAll(roles)
|
||||
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
addAll(user?.roles.orEmpty())
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<CmsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<NewsDto?>(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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<de.harheimertc.data.SeasonDto>,
|
||||
selectedSeason: String,
|
||||
onSeasonSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||
BoxWithConstraints {
|
||||
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||
Text("v")
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
seasons.forEach { season ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(season.label.ifBlank { season.slug }) },
|
||||
onClick = {
|
||||
open = false
|
||||
onSeasonSelected(season.slug)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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
|
||||
|
||||
@@ -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<Mannschaft> = emptyList(),
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
val selectedSeason: String = "",
|
||||
val seasonsLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -27,17 +31,76 @@ class MannschaftenViewModel @Inject constructor(
|
||||
val state: StateFlow<MannschaftenUiState> = _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<MannschaftDetailUiState> = _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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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<MembersUiState> = _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<MemberNewsUiState> = _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<QttrUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<MemberAreaUiState> = _state
|
||||
|
||||
init {
|
||||
loadBirthdays()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
loadBirthdays()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadBirthdays() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package de.harheimertc.ui.screens.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.NotificationPreferences
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.navigation.NavigationUiState
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
private val notificationTimes = (6..22).flatMap { hour ->
|
||||
listOf("%02d:00".format(hour), "%02d:30".format(hour))
|
||||
}.dropLast(1)
|
||||
|
||||
@Composable
|
||||
fun NotificationSettingsScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
navigationState: NavigationUiState,
|
||||
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember { mutableStateOf(HarheimerNotifications.hasNotificationPermission(context)) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasPermission = granted || HarheimerNotifications.hasNotificationPermission(context)
|
||||
}
|
||||
val isBoard = "vorstand" in navigationState.roles
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Benachrichtigungen", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text("Persönliche Android-Benachrichtigungen verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Android-Berechtigung") {
|
||||
val permissionText = if (hasPermission) {
|
||||
"Benachrichtigungen sind im Android-System erlaubt."
|
||||
} else {
|
||||
"Benachrichtigungen sind im Android-System noch nicht erlaubt."
|
||||
}
|
||||
Text(permissionText, color = if (hasPermission) Color(0xFF166534) else Accent700)
|
||||
if (!hasPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Button(
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Berechtigung anfordern")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
|
||||
} else {
|
||||
item {
|
||||
NotificationCard("Benachrichtigungszeit") {
|
||||
Text("Automatische Benachrichtigungen werden zu dieser Uhrzeit ausgelöst.", color = Accent700)
|
||||
TimeSelection(state.settings.notificationTime) { selectedTime ->
|
||||
viewModel.update(state.settings.copy(notificationTime = selectedTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("News") {
|
||||
ToggleRow("Neue News", state.settings.newNews) {
|
||||
viewModel.update(state.settings.copy(newNews = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Termine") {
|
||||
ToggleRow("Neue Termine", state.settings.newEvents) {
|
||||
viewModel.update(state.settings.copy(newEvents = it))
|
||||
}
|
||||
ToggleRow("Termine von heute", state.settings.eventsToday) {
|
||||
viewModel.update(state.settings.copy(eventsToday = it))
|
||||
}
|
||||
ToggleRow("Termine von morgen", state.settings.eventsTomorrow) {
|
||||
viewModel.update(state.settings.copy(eventsTomorrow = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Punktspiele") {
|
||||
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
||||
viewModel.update(state.settings.copy(ownTeamMatches = it))
|
||||
}
|
||||
Text("Die eigene Mannschaft wird aus dem Namen und der Mannschaftszusammensetzung ermittelt.", color = Accent700)
|
||||
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||
viewModel.update(state.settings.copy(allTeamMatches = it))
|
||||
}
|
||||
TeamSelection(
|
||||
teams = state.teams,
|
||||
seasons = state.seasons,
|
||||
selectedSeason = state.settings.selectedTeamSeason,
|
||||
settings = state.settings,
|
||||
onSelectSeason = viewModel::selectSeason,
|
||||
onToggleTeam = viewModel::toggleTeam,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Mitglieder") {
|
||||
ToggleRow("Geburtstage", state.settings.birthdays) {
|
||||
viewModel.update(state.settings.copy(birthdays = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBoard) {
|
||||
item {
|
||||
NotificationCard("Vorstand") {
|
||||
ToggleRow("Neue Kontaktanfrage", state.settings.newContactRequest) {
|
||||
viewModel.update(state.settings.copy(newContactRequest = it))
|
||||
}
|
||||
ToggleRow("Neue Benutzerregistrierung", state.settings.newUserRegistration) {
|
||||
viewModel.update(state.settings.copy(newUserRegistration = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.saveError?.let { message ->
|
||||
item { Text(message, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
|
||||
state.error?.let { message ->
|
||||
item {
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = viewModel::load) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(label, color = Accent900, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
notificationTimes.forEach { time ->
|
||||
if (time == selectedTime) {
|
||||
Button(onClick = { onSelectTime(time) }) { Text(time) }
|
||||
} else {
|
||||
OutlinedButton(onClick = { onSelectTime(time) }) { Text(time) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamSelection(
|
||||
teams: List<Mannschaft>,
|
||||
seasons: List<String>,
|
||||
selectedSeason: String?,
|
||||
settings: NotificationPreferences,
|
||||
onSelectSeason: (String) -> Unit,
|
||||
onToggleTeam: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
|
||||
if (seasons.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
seasons.forEach { season ->
|
||||
val selected = season == selectedSeason
|
||||
if (selected) {
|
||||
Button(onClick = { onSelectSeason(season) }) { Text(season) }
|
||||
} else {
|
||||
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (teams.isEmpty()) {
|
||||
Text("Keine Mannschaften verfügbar.", color = Accent700)
|
||||
} else {
|
||||
teams.forEach { team ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Checkbox(
|
||||
checked = team.slug in settings.selectedTeamSlugs,
|
||||
onCheckedChange = { onToggleTeam(team.slug, it) },
|
||||
)
|
||||
Text(team.mannschaft, color = Accent900)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.harheimertc.ui.screens.notifications
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.NotificationPreferences
|
||||
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NotificationSettingsUiState(
|
||||
val loading: Boolean = true,
|
||||
val settings: NotificationPreferences = NotificationPreferences(),
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
val seasons: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
val saveError: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NotificationSettingsViewModel @Inject constructor(
|
||||
private val preferencesRepository: NotificationPreferencesRepository,
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
||||
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
||||
val seasons = seasonsResponse?.seasons.orEmpty()
|
||||
val selectedSeason = storedSettings.selectedTeamSeason
|
||||
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||
?: seasons.firstOrNull()
|
||||
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSeason(season: String) {
|
||||
val current = _state.value.settings
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(settings: NotificationPreferences) {
|
||||
preferencesRepository.saveLocal(settings)
|
||||
_state.value = _state.value.copy(settings = settings, saveError = null)
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.saveRemote(settings)
|
||||
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTeam(slug: String, selected: Boolean) {
|
||||
val current = _state.value.settings
|
||||
val nextTeams = if (selected) {
|
||||
current.selectedTeamSlugs + slug
|
||||
} else {
|
||||
current.selectedTeamSlugs - slug
|
||||
}
|
||||
update(current.copy(selectedTeamSlugs = nextTeams))
|
||||
}
|
||||
|
||||
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, syncRemote: Boolean = false) {
|
||||
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||
.onSuccess { teams ->
|
||||
val knownSlugs = teams.map { it.slug }.toSet()
|
||||
val nextSettings = settings.copy(selectedTeamSlugs = settings.selectedTeamSlugs.intersect(knownSlugs))
|
||||
preferencesRepository.saveLocal(nextSettings)
|
||||
val saveError = if (syncRemote) {
|
||||
preferencesRepository.saveRemote(nextSettings).exceptionOrNull()?.message
|
||||
} else null
|
||||
_state.value = NotificationSettingsUiState(
|
||||
loading = false,
|
||||
settings = nextSettings,
|
||||
teams = teams,
|
||||
seasons = seasons,
|
||||
saveError = saveError,
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
preferencesRepository.saveLocal(settings)
|
||||
val saveError = if (syncRemote) {
|
||||
preferencesRepository.saveRemote(settings).exceptionOrNull()?.message
|
||||
} else null
|
||||
_state.value = NotificationSettingsUiState(
|
||||
loading = false,
|
||||
settings = settings,
|
||||
seasons = seasons,
|
||||
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
||||
saveError = saveError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,53 +78,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News Modal -->
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
@click.self="closeNewsModal"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<Calendar
|
||||
:size="16"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ formatDate(selectedNews.created) }}
|
||||
</div>
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||
{{ selectedNews.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@click="closeNewsModal"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Transition name="news-modal">
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="modalTitleId"
|
||||
@click.self="closeNewsModal"
|
||||
>
|
||||
<article class="w-full max-w-3xl max-h-[min(44rem,calc(100vh-3rem))] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
<header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-3 inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-800">
|
||||
<Calendar :size="15" />
|
||||
<time :datetime="selectedNews.created">
|
||||
{{ formatDate(selectedNews.created) }}
|
||||
</time>
|
||||
</div>
|
||||
<h2
|
||||
:id="modalTitleId"
|
||||
class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
|
||||
>
|
||||
{{ selectedNews.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md border border-gray-200 bg-white p-2 text-gray-500 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-label="News schließen"
|
||||
@click="closeNewsModal"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Modal Content (scrollable) -->
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap">
|
||||
{{ selectedNews.content }}
|
||||
</div>
|
||||
<div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
|
||||
<div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
|
||||
{{ selectedNews.content }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Calendar, X } from 'lucide-vue-next'
|
||||
|
||||
const news = ref([])
|
||||
const selectedNews = ref(null)
|
||||
const isLoading = ref(true)
|
||||
const modalTitleId = 'public-news-modal-title'
|
||||
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
@@ -164,19 +173,30 @@ const getGridClass = () => {
|
||||
|
||||
const openNewsModal = (item) => {
|
||||
selectedNews.value = item
|
||||
// Verhindere Scrollen im Hintergrund
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.addEventListener('keydown', handleModalKeydown)
|
||||
}
|
||||
|
||||
const closeNewsModal = () => {
|
||||
selectedNews.value = null
|
||||
// Erlaube Scrollen wieder
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', handleModalKeydown)
|
||||
}
|
||||
|
||||
const handleModalKeydown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeNewsModal()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNews()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', handleModalKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -186,5 +206,20 @@ onMounted(() => {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-modal-content {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.news-modal-enter-active,
|
||||
.news-modal-leave-active {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.news-modal-enter-from,
|
||||
.news-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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..."
|
||||
|
||||
67
google-services.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "174247719758",
|
||||
"project_id": "harheimer-tc",
|
||||
"storage_bucket": "harheimer-tc.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:174247719758:android:04240f5a6ecc06b8eba41f",
|
||||
"android_client_info": {
|
||||
"package_name": "de.harheimertc"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:174247719758:android:852797b868a6413feba41f",
|
||||
"android_client_info": {
|
||||
"package_name": "de.harheimertc.local"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:174247719758:android:0663d7ca236b5db2eba41f",
|
||||
"android_client_info": {
|
||||
"package_name": "de.harheimertc.test"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Passwort zurücksetzen
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
|
||||
Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
125
pages/passwort-zuruecksetzen.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-display font-bold text-gray-900">
|
||||
Neues Passwort setzen
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Vergeben Sie ein neues Passwort für Ihren Zugang.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-lg p-8">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<div v-if="!token" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p class="text-sm text-red-800">Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="passwordRepeat" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Neues Passwort wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="passwordRepeat"
|
||||
v-model="passwordRepeat"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p class="text-sm text-red-800">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p class="text-sm text-green-800 flex items-center">
|
||||
<Check :size="18" class="mr-2" />
|
||||
{{ successMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !token || Boolean(successMessage)"
|
||||
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
|
||||
<span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||
Zurück zum Login
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { Check, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => String(route.query.token || '').trim())
|
||||
const password = ref('')
|
||||
const passwordRepeat = ref('')
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
if (password.value.length < 8) {
|
||||
errorMessage.value = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== passwordRepeat.value) {
|
||||
errorMessage.value = 'Die Passwörter stimmen nicht überein.'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/auth/reset-password/complete', {
|
||||
method: 'POST',
|
||||
body: { token: token.value, password: password.value }
|
||||
})
|
||||
successMessage.value = response?.message || 'Ihr Passwort wurde geändert.'
|
||||
password.value = ''
|
||||
passwordRepeat.value = ''
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || 'Der Reset-Link ist ungültig oder abgelaufen.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'Neues Passwort setzen - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
25
plugins/auth-sync.client.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const syncAuthState = async () => {
|
||||
await authStore.checkAuth()
|
||||
}
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
syncAuthState()
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
syncAuthState()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncAuthState()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
165
scripts/data-backup-restore.js
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path'
|
||||
import {
|
||||
getBackupDirectoryForDataFile,
|
||||
listDataFileBackups,
|
||||
restoreDataFileBackup
|
||||
} from '../server/utils/data-file-rotation.js'
|
||||
|
||||
const FILES = {
|
||||
'users.json': getDataPath('users.json'),
|
||||
'sessions.json': getDataPath('sessions.json'),
|
||||
'members.json': getDataPath('members.json'),
|
||||
'newsletter-subscribers.json': getDataPath('newsletter-subscribers.json'),
|
||||
'news.json': getDataPath('news.json'),
|
||||
'termine.csv': getDataPath('termine.csv'),
|
||||
'contact-requests.json': getDataPath('contact-requests.json')
|
||||
}
|
||||
|
||||
function getDataPath(filename) {
|
||||
const cwd = process.cwd()
|
||||
if (cwd.endsWith('.output')) {
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
return path.join(cwd, '../server/data', filename)
|
||||
}
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
return path.join(cwd, 'server/data', filename)
|
||||
}
|
||||
|
||||
function parseArg(name) {
|
||||
const index = process.argv.findIndex((arg) => arg === name)
|
||||
if (index === -1) return null
|
||||
const next = process.argv[index + 1]
|
||||
if (!next || next.startsWith('--')) return null
|
||||
return next
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return process.argv.includes(name)
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log('Verwendung:')
|
||||
console.log(' node scripts/data-backup-restore.js list [--file users.json]')
|
||||
console.log(' node scripts/data-backup-restore.js restore --file users.json --latest')
|
||||
console.log(' node scripts/data-backup-restore.js restore --file users.json --backup <backup-datei.bak>')
|
||||
console.log('')
|
||||
console.log('Optionen:')
|
||||
console.log(' --file Eine der bekannten Daten-Dateien')
|
||||
console.log(' --latest Stellt das neueste Backup wieder her')
|
||||
console.log(' --backup Konkreter Backup-Dateiname (*.bak)')
|
||||
console.log('')
|
||||
console.log('Bekannte Dateien:')
|
||||
Object.keys(FILES).forEach((name) => console.log(` - ${name}`))
|
||||
}
|
||||
|
||||
async function listCommand() {
|
||||
const requestedFile = parseArg('--file')
|
||||
const names = requestedFile ? [requestedFile] : Object.keys(FILES)
|
||||
|
||||
for (const name of names) {
|
||||
const dataPath = FILES[name]
|
||||
if (!dataPath) {
|
||||
console.error(`Unbekannte Datei: ${name}`)
|
||||
process.exitCode = 1
|
||||
continue
|
||||
}
|
||||
|
||||
const backups = await listDataFileBackups(dataPath)
|
||||
const backupDir = getBackupDirectoryForDataFile(dataPath)
|
||||
|
||||
console.log(`\n${name}`)
|
||||
console.log(` Datenpfad: ${dataPath}`)
|
||||
console.log(` Backup-Ordner: ${backupDir}`)
|
||||
|
||||
if (backups.length === 0) {
|
||||
console.log(' Backups: keine')
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(` Backups (${backups.length}, neuestes zuerst):`)
|
||||
backups.slice(0, 15).forEach((backup) => {
|
||||
console.log(` - ${backup}`)
|
||||
})
|
||||
if (backups.length > 15) {
|
||||
console.log(` ... (${backups.length - 15} weitere)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCommand() {
|
||||
const fileName = parseArg('--file')
|
||||
if (!fileName) {
|
||||
console.error('Fehlend: --file <datei>')
|
||||
printUsage()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dataPath = FILES[fileName]
|
||||
if (!dataPath) {
|
||||
console.error(`Unbekannte Datei: ${fileName}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const backups = await listDataFileBackups(dataPath)
|
||||
if (backups.length === 0) {
|
||||
console.error(`Keine Backups gefunden für ${fileName}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const backupName = parseArg('--backup')
|
||||
const latest = hasFlag('--latest')
|
||||
|
||||
let targetBackup = backupName
|
||||
if (!targetBackup && latest) {
|
||||
targetBackup = backups[0]
|
||||
}
|
||||
|
||||
if (!targetBackup) {
|
||||
console.error('Bitte --latest oder --backup <name> angeben')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!backups.includes(targetBackup)) {
|
||||
console.error(`Backup nicht gefunden: ${targetBackup}`)
|
||||
console.error('Nutzen Sie zuerst: node scripts/data-backup-restore.js list --file <datei>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const result = await restoreDataFileBackup(dataPath, targetBackup)
|
||||
|
||||
console.log(`Wiederherstellung abgeschlossen: ${fileName}`)
|
||||
console.log(` Eingespieltes Backup: ${targetBackup}`)
|
||||
if (result.backupPath) {
|
||||
console.log(` Backup des vorherigen Zustands: ${result.backupPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2]
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
if (command === 'list') {
|
||||
await listCommand()
|
||||
return
|
||||
}
|
||||
|
||||
if (command === 'restore') {
|
||||
await restoreCommand()
|
||||
return
|
||||
}
|
||||
|
||||
console.error(`Unbekannter Befehl: ${command}`)
|
||||
printUsage()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fehler im Backup/Restore-Skript:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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, '"')
|
||||
.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: `
|
||||
<h2>Passwort zurücksetzen</h2>
|
||||
<p>Hallo ${user.name},</p>
|
||||
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
|
||||
<p>Ihr temporäres Passwort lautet: <strong>${tempPassword}</strong></p>
|
||||
<p>Bitte melden Sie sich damit an und ändern Sie Ihr Passwort im Mitgliederbereich.</p>
|
||||
<br>
|
||||
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
|
||||
<br>
|
||||
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
||||
`
|
||||
}
|
||||
|
||||
await logStep('mail_send', 'started', { userId: user.id })
|
||||
try {
|
||||
await transporter.sendMail(mailOptions)
|
||||
} catch (error) {
|
||||
await logStep('mail_send', 'failed', { userId: user.id, error })
|
||||
throw error
|
||||
}
|
||||
await logStep('mail_send', 'completed', { userId: user.id })
|
||||
}
|
||||
|
||||
// Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren.
|
||||
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: `
|
||||
<h2>Passwort zurücksetzen</h2>
|
||||
<p>Hallo ${displayName},</p>
|
||||
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
|
||||
<p>Bitte klicken Sie auf den folgenden Link und vergeben Sie dort ein neues Passwort. Der Link ist ${RESET_TOKEN_TTL_MINUTES} Minuten gültig:</p>
|
||||
<p><a href="${resetUrl}">Neues Passwort setzen</a></p>
|
||||
<p>Ihr bisheriges Passwort bleibt gültig, bis Sie über diesen Link ein neues Passwort gesetzt haben.</p>
|
||||
<br>
|
||||
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
|
||||
<br>
|
||||
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
||||
`
|
||||
}
|
||||
|
||||
await logStep('mail_send', 'started', { userId: user.id })
|
||||
try {
|
||||
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.'
|
||||
|
||||
82
server/api/auth/reset-password/complete.post.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import crypto from 'crypto'
|
||||
import { hashPassword, readUsers, revokeRefreshSessionsForUser, writeUsers } from '../../../utils/auth.js'
|
||||
import { getClientIp } from '../../../utils/rate-limit.js'
|
||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||
import { writePasswordResetLog } from '../../../utils/password-reset-log.js'
|
||||
|
||||
function hashResetToken(token) {
|
||||
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function isStrongEnoughPassword(password) {
|
||||
return typeof password === 'string' && password.length >= 8
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
const ip = getClientIp(event)
|
||||
const body = await readBody(event)
|
||||
const token = String(body?.token || '').trim()
|
||||
const password = String(body?.password || '')
|
||||
|
||||
const logStep = async (step, status, detail = {}) => {
|
||||
try {
|
||||
await writePasswordResetLog({ requestId, email: detail.email || '', ip, step, status, ...detail })
|
||||
} catch (logError) {
|
||||
console.error('Password-Reset-Diagnoselog-Fehler:', logError)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
await logStep('complete_validation', 'failed', { reason: 'token_missing' })
|
||||
throw createError({ statusCode: 400, message: 'Reset-Link fehlt.' })
|
||||
}
|
||||
|
||||
if (!isStrongEnoughPassword(password)) {
|
||||
await logStep('complete_validation', 'failed', { reason: 'password_too_short' })
|
||||
throw createError({ statusCode: 400, message: 'Das Passwort muss mindestens 8 Zeichen lang sein.' })
|
||||
}
|
||||
|
||||
const users = await readUsers()
|
||||
const tokenHash = hashResetToken(token)
|
||||
const now = Date.now()
|
||||
let matchedUser = null
|
||||
let matchedToken = null
|
||||
|
||||
for (const user of users) {
|
||||
const tokens = Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : []
|
||||
const candidate = tokens.find(entry => entry.tokenHash === tokenHash)
|
||||
if (candidate) {
|
||||
matchedUser = user
|
||||
matchedToken = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedUser || !matchedToken || matchedToken.usedAt || new Date(matchedToken.expiresAt).getTime() <= now) {
|
||||
await logStep('complete_token', 'failed', { reason: 'invalid_or_expired' })
|
||||
throw createError({ statusCode: 400, message: 'Der Reset-Link ist ungültig oder abgelaufen.' })
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString()
|
||||
matchedUser.password = await hashPassword(password)
|
||||
matchedUser.passwordResetRequired = false
|
||||
matchedToken.usedAt = nowIso
|
||||
matchedUser.passwordResetTokens = (Array.isArray(matchedUser.passwordResetTokens) ? matchedUser.passwordResetTokens : [])
|
||||
.filter(entry => entry.usedAt || new Date(entry.expiresAt).getTime() > now)
|
||||
|
||||
const stored = await writeUsers(users)
|
||||
if (!stored) {
|
||||
await logStep('complete_password_storage', 'failed', { userId: matchedUser.id, email: matchedUser.email, reason: 'write_failed' })
|
||||
throw createError({ statusCode: 500, message: 'Das neue Passwort konnte nicht gespeichert werden.' })
|
||||
}
|
||||
|
||||
await revokeRefreshSessionsForUser(matchedUser.id, 'password_reset_completed')
|
||||
await writeAuditLog('auth.reset.complete', { ip, userId: matchedUser.id, requestId })
|
||||
await logStep('complete_password_storage', 'completed', { userId: matchedUser.id, email: matchedUser.email })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ihr Passwort wurde geändert. Sie können sich jetzt mit dem neuen Passwort anmelden.'
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 || {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
server/api/profile/notifications.get.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { verifyToken, getUserFromToken } from '../../utils/auth.js'
|
||||
import { notificationSettingsForUser } from '../../utils/notification-settings.js'
|
||||
|
||||
function tokenFromEvent(event) {
|
||||
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
}
|
||||
|
||||
async function requireAuthenticatedUser(event) {
|
||||
const token = tokenFromEvent(event)
|
||||
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||
const user = await getUserFromToken(token)
|
||||
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
|
||||
return { token, decoded, user }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { user } = await requireAuthenticatedUser(event)
|
||||
return {
|
||||
success: true,
|
||||
settings: notificationSettingsForUser(user)
|
||||
}
|
||||
})
|
||||
34
server/api/profile/notifications.put.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
|
||||
import { sanitizeNotificationSettings } from '../../utils/notification-settings.js'
|
||||
|
||||
function tokenFromEvent(event) {
|
||||
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
}
|
||||
|
||||
async function requireAuthenticatedUser(event) {
|
||||
const token = tokenFromEvent(event)
|
||||
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||
const user = await getUserFromToken(token)
|
||||
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
|
||||
return { token, decoded, user }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { decoded } = await requireAuthenticatedUser(event)
|
||||
const body = await readBody(event)
|
||||
const settings = sanitizeNotificationSettings(body?.settings || body || {})
|
||||
const users = await readUsers()
|
||||
const userIndex = users.findIndex(user => user.id === decoded.id)
|
||||
if (userIndex === -1) {
|
||||
throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
|
||||
}
|
||||
users[userIndex].notificationSettings = settings
|
||||
await writeUsers(users)
|
||||
return {
|
||||
success: true,
|
||||
message: 'Benachrichtigungseinstellungen gespeichert.',
|
||||
settings
|
||||
}
|
||||
})
|
||||
29
server/api/profile/push-token.post.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
|
||||
import { upsertPushToken } from '../../utils/push-notifications.js'
|
||||
|
||||
function tokenFromEvent(event) {
|
||||
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = tokenFromEvent(event)
|
||||
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||
const sessionUser = await getUserFromToken(token)
|
||||
if (!sessionUser) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
|
||||
const body = await readBody(event)
|
||||
if (!body?.token || typeof body.token !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'Push-Token fehlt.' })
|
||||
}
|
||||
const users = await readUsers()
|
||||
const userIndex = users.findIndex(user => user.id === decoded.id)
|
||||
if (userIndex === -1) throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
|
||||
upsertPushToken(users[userIndex], {
|
||||
token: body.token,
|
||||
platform: body.platform || 'android',
|
||||
appVersion: body.appVersion || null
|
||||
})
|
||||
await writeUsers(users)
|
||||
return { success: true, message: 'Push-Token gespeichert.' }
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
143
server/utils/data-file-rotation.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||
|
||||
function getProjectRoot() {
|
||||
const cwd = process.cwd()
|
||||
if (cwd.endsWith('.output')) {
|
||||
return path.resolve(cwd, '..')
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
function getBackupRoot() {
|
||||
const configured = process.env.DATA_FILE_BACKUP_DIR
|
||||
if (!configured) {
|
||||
return path.join(getProjectRoot(), 'backups', 'data-rotation')
|
||||
}
|
||||
if (path.isAbsolute(configured)) {
|
||||
return configured
|
||||
}
|
||||
return path.join(getProjectRoot(), configured)
|
||||
}
|
||||
|
||||
function sanitizeFileKey(filePath) {
|
||||
const projectRoot = getProjectRoot()
|
||||
const relative = path.relative(projectRoot, filePath)
|
||||
const normalized = relative.split(path.sep).join('__')
|
||||
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
}
|
||||
|
||||
function buildBackupName(date = new Date()) {
|
||||
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
|
||||
}
|
||||
|
||||
export function resolveDataFileBackupPath(backupDir, backupName) {
|
||||
if (typeof backupName !== 'string' || !backupName.endsWith('.bak') || path.basename(backupName) !== backupName) {
|
||||
throw new Error('Ungueltiger Backup-Dateiname')
|
||||
}
|
||||
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedBackupDir = path.resolve(backupDir)
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedBackupPath = path.resolve(resolvedBackupDir, backupName)
|
||||
if (path.dirname(resolvedBackupPath) !== resolvedBackupDir) {
|
||||
throw new Error('Backup-Datei liegt ausserhalb des Backup-Ordners')
|
||||
}
|
||||
return resolvedBackupPath
|
||||
}
|
||||
|
||||
async function ensureDirectory(dirPath) {
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
async function rotateOldBackups(backupDir, maxBackups) {
|
||||
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||
const backups = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const overflowCount = Math.max(0, backups.length - maxBackups)
|
||||
if (overflowCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const toDelete = backups.slice(0, overflowCount)
|
||||
await Promise.all(toDelete.map((name) => fs.unlink(resolveDataFileBackupPath(backupDir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
export function getBackupDirectoryForDataFile(filePath) {
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
|
||||
}
|
||||
|
||||
export async function listDataFileBackups(filePath) {
|
||||
const backupDir = getBackupDirectoryForDataFile(filePath)
|
||||
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse()
|
||||
}
|
||||
|
||||
export async function writeDataFileWithRotation(filePath, content, {
|
||||
encoding = 'utf-8',
|
||||
maxBackups = DEFAULT_MAX_BACKUPS
|
||||
} = {}) {
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
await ensureDirectory(path.dirname(resolvedPath))
|
||||
|
||||
let existingContent = null
|
||||
try {
|
||||
existingContent = await fs.readFile(resolvedPath, encoding)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (existingContent !== null && existingContent === content) {
|
||||
return {
|
||||
changed: false,
|
||||
backupPath: null
|
||||
}
|
||||
}
|
||||
|
||||
let backupPath = null
|
||||
if (existingContent !== null) {
|
||||
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||
await ensureDirectory(backupDir)
|
||||
backupPath = resolveDataFileBackupPath(backupDir, buildBackupName())
|
||||
await fs.copyFile(resolvedPath, backupPath)
|
||||
await rotateOldBackups(backupDir, maxBackups)
|
||||
}
|
||||
|
||||
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
await fs.writeFile(tmpPath, content, encoding)
|
||||
await fs.rename(tmpPath, resolvedPath)
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
backupPath
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||
const sourceBackupPath = resolveDataFileBackupPath(backupDir, backupName)
|
||||
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
|
||||
|
||||
return writeDataFileWithRotation(resolvedPath, backupContent, options)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
55
server/utils/notification-settings.js
Normal file
@@ -0,0 +1,55 @@
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
|
||||
newNews: false,
|
||||
newEvents: false,
|
||||
eventsToday: false,
|
||||
eventsTomorrow: false,
|
||||
ownTeamMatches: false,
|
||||
allTeamMatches: false,
|
||||
birthdays: false,
|
||||
newContactRequest: false,
|
||||
newUserRegistration: false,
|
||||
selectedTeamSlugs: [],
|
||||
selectedTeamSeason: null,
|
||||
notificationTime: '09:00'
|
||||
})
|
||||
|
||||
function coerceBoolean(value) {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function sanitizeNotificationSettings(input = {}) {
|
||||
const selectedTeamSlugs = Array.isArray(input.selectedTeamSlugs)
|
||||
? input.selectedTeamSlugs
|
||||
.map(value => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 50)
|
||||
: []
|
||||
const selectedTeamSeason = typeof input.selectedTeamSeason === 'string' && input.selectedTeamSeason.trim()
|
||||
? input.selectedTeamSeason.trim().slice(0, 30)
|
||||
: null
|
||||
const notificationTime = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(input.notificationTime || ''))
|
||||
? String(input.notificationTime)
|
||||
: DEFAULT_NOTIFICATION_SETTINGS.notificationTime
|
||||
|
||||
return {
|
||||
newNews: coerceBoolean(input.newNews),
|
||||
newEvents: coerceBoolean(input.newEvents),
|
||||
eventsToday: coerceBoolean(input.eventsToday),
|
||||
eventsTomorrow: coerceBoolean(input.eventsTomorrow),
|
||||
ownTeamMatches: coerceBoolean(input.ownTeamMatches),
|
||||
allTeamMatches: coerceBoolean(input.allTeamMatches),
|
||||
birthdays: coerceBoolean(input.birthdays),
|
||||
newContactRequest: coerceBoolean(input.newContactRequest),
|
||||
newUserRegistration: coerceBoolean(input.newUserRegistration),
|
||||
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
|
||||
selectedTeamSeason,
|
||||
notificationTime
|
||||
}
|
||||
}
|
||||
|
||||
export function notificationSettingsForUser(user) {
|
||||
return sanitizeNotificationSettings({
|
||||
...DEFAULT_NOTIFICATION_SETTINGS,
|
||||
...(user?.notificationSettings || user?.notifications || {})
|
||||
})
|
||||
}
|
||||
190
server/utils/push-notifications.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import crypto from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { readUsers, writeUsers, isHiddenUser } from './auth.js'
|
||||
import { notificationSettingsForUser } from './notification-settings.js'
|
||||
|
||||
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
|
||||
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
const tokenCache = { accessToken: null, expiresAt: 0 }
|
||||
|
||||
function base64Url(input) {
|
||||
return Buffer.from(input).toString('base64url')
|
||||
}
|
||||
|
||||
function projectIdFromServiceAccount(serviceAccount) {
|
||||
return process.env.FCM_PROJECT_ID || serviceAccount.project_id
|
||||
}
|
||||
|
||||
function serviceAccountCandidatePaths() {
|
||||
const filename = 'harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
|
||||
const cwd = process.cwd()
|
||||
const candidates = []
|
||||
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(process.env.GOOGLE_APPLICATION_CREDENTIALS)
|
||||
candidates.push(path.join(cwd, 'server/data', filename))
|
||||
candidates.push(path.join(cwd, '../server/data', filename))
|
||||
return [...new Set(candidates)]
|
||||
}
|
||||
|
||||
async function readServiceAccount() {
|
||||
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
|
||||
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
|
||||
}
|
||||
for (const candidate of serviceAccountCandidatePaths()) {
|
||||
try {
|
||||
const raw = await fs.readFile(candidate, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
console.warn(`FCM Service-Account konnte nicht gelesen werden (${candidate}): ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getAccessToken(serviceAccount) {
|
||||
if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) {
|
||||
return tokenCache.accessToken
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const assertion = [
|
||||
base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
|
||||
base64Url(JSON.stringify({
|
||||
iss: serviceAccount.client_email,
|
||||
scope: FCM_SCOPE,
|
||||
aud: TOKEN_URL,
|
||||
iat: now,
|
||||
exp: now + 3600
|
||||
}))
|
||||
].join('.')
|
||||
const signature = crypto
|
||||
.createSign('RSA-SHA256')
|
||||
.update(assertion)
|
||||
.sign(serviceAccount.private_key, 'base64url')
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: `${assertion}.${signature}`
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`)
|
||||
}
|
||||
const body = await response.json()
|
||||
tokenCache.accessToken = body.access_token
|
||||
tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000
|
||||
return tokenCache.accessToken
|
||||
}
|
||||
|
||||
function pushTokensForUser(user) {
|
||||
return Array.isArray(user.pushTokens)
|
||||
? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android')
|
||||
: []
|
||||
}
|
||||
|
||||
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) return user
|
||||
const now = new Date().toISOString()
|
||||
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
|
||||
const next = tokens.filter(entry => entry?.token !== normalizedToken)
|
||||
next.push({
|
||||
token: normalizedToken,
|
||||
platform: String(platform || 'android').slice(0, 30),
|
||||
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
||||
updatedAt: now,
|
||||
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
||||
})
|
||||
user.pushTokens = next.slice(-20)
|
||||
return user
|
||||
}
|
||||
|
||||
async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, data = {} }) {
|
||||
const projectId = projectIdFromServiceAccount(serviceAccount)
|
||||
if (!projectId) throw new Error('FCM project_id fehlt.')
|
||||
const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
notification: { title, body },
|
||||
data,
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
channel_id: 'harheimer_tc_updates',
|
||||
click_action: 'OPEN_NEWS'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNewNewsPush(news) {
|
||||
const serviceAccount = await readServiceAccount()
|
||||
if (!serviceAccount) {
|
||||
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
||||
return { sent: 0, skipped: true }
|
||||
}
|
||||
const accessToken = await getAccessToken(serviceAccount)
|
||||
const users = await readUsers()
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
let removed = 0
|
||||
let recipients = 0
|
||||
let tokenCount = 0
|
||||
let changed = false
|
||||
const title = 'Neue News'
|
||||
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
|
||||
const data = {
|
||||
type: 'news',
|
||||
newsId: String(news.id || ''),
|
||||
title,
|
||||
body,
|
||||
notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (isHiddenUser(user)) continue
|
||||
const settings = notificationSettingsForUser(user)
|
||||
if (!settings.newNews) continue
|
||||
recipients += 1
|
||||
const tokens = pushTokensForUser(user)
|
||||
tokenCount += tokens.length
|
||||
const validTokens = []
|
||||
for (const entry of tokens) {
|
||||
try {
|
||||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
|
||||
sent += 1
|
||||
validTokens.push(entry)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
console.error('FCM News-Push fehlgeschlagen:', error.message)
|
||||
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
||||
validTokens.push(entry)
|
||||
} else {
|
||||
removed += 1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (validTokens.length !== tokens.length) {
|
||||
user.pushTokens = validTokens
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await writeUsers(users)
|
||||
return { sent, failed, removed, recipients, tokenCount, skipped: false }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
BIN
temp/device-35433-after-intern-fix.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
temp/device-35433-cms-after-split-2.png
Normal file
|
After Width: | Height: | Size: 911 KiB |
BIN
temp/device-35433-cms-after-split.png
Normal file
|
After Width: | Height: | Size: 911 KiB |
BIN
temp/device-35433-cms-open.png
Normal file
|
After Width: | Height: | Size: 873 KiB |
BIN
temp/device-35433-home.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
temp/device-35433-intern-after-split.png
Normal file
|
After Width: | Height: | Size: 907 KiB |
0
temp/device-37165-arrow-check.png
Normal file
BIN
temp/device-38281-after-fix.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
temp/device-38281-cms-2.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
temp/device-38281-cms-open.png
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
temp/device-38281-cms.png
Normal file
|
After Width: | Height: | Size: 899 KiB |