diff --git a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt index df1737b..0927cbd 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt @@ -1,11 +1,13 @@ package de.harheimertc.data import android.content.Context +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.squareup.moshi.Moshi import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.GeneralSecurityException import javax.inject.Inject import javax.inject.Singleton @@ -14,6 +16,7 @@ class SecureOfflineCache @Inject constructor( @param:ApplicationContext private val context: Context, private val moshi: Moshi, ) { + private val tag = "SecureOfflineCache" private companion object { const val KEY_BIRTHDAYS = "birthdays" const val KEY_QTTR_VALUES = "qttr_values" @@ -29,6 +32,10 @@ class SecureOfflineCache @Inject constructor( } private val preferences by lazy { + buildEncryptedPreferences() + } + + private fun buildEncryptedPreferences() = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -39,6 +46,28 @@ class SecureOfflineCache @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) + } catch (error: GeneralSecurityException) { + recoverEncryptedPreferences(error) + } catch (error: RuntimeException) { + recoverEncryptedPreferences(error) + } + + private fun recoverEncryptedPreferences(error: Throwable) = try { + Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error) + context.deleteSharedPreferences("harheimertc_offline_cache") + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "harheimertc_offline_cache", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (retryError: Throwable) { + Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError) + throw retryError } fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt index d89534d..ba8fb1f 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt @@ -1,10 +1,12 @@ package de.harheimertc.repositories import android.content.Context +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import de.harheimertc.security.DeviceKeyManager +import java.security.GeneralSecurityException import javax.inject.Inject import javax.inject.Singleton @@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor( @param:ApplicationContext private val context: Context, private val deviceKeyManager: DeviceKeyManager, ) : AuthRepository { + private val tag = "AuthRepository" private val tokenKey = "auth_token" private val refreshTokenKey = "auth_refresh_token" private val sessionIdKey = "auth_session_id" private val preferences by lazy { + buildEncryptedPreferences() + } + + private fun buildEncryptedPreferences() = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) + } catch (error: GeneralSecurityException) { + recoverEncryptedPreferences(error) + } catch (error: RuntimeException) { + recoverEncryptedPreferences(error) + } + + private fun recoverEncryptedPreferences(error: Throwable) = try { + Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error) + context.deleteSharedPreferences("harheimertc_auth") + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "harheimertc_auth", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (retryError: Throwable) { + Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError) + throw retryError } override fun getToken(): String? = preferences.getString(tokenKey, null) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt index a004214..d5b556c 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt @@ -1,12 +1,14 @@ package de.harheimertc.repositories import android.content.Context +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.squareup.moshi.Moshi import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext import de.harheimertc.data.HomepageSectionDto +import java.security.GeneralSecurityException import javax.inject.Inject import javax.inject.Singleton @@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor( @param:ApplicationContext private val context: Context, private val moshi: Moshi, ) { + private val tag = "HomeLayoutPreferences" private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java) private val sectionListAdapter = moshi.adapter>(sectionListType) private val preferences by lazy { + buildEncryptedPreferences() + } + + private fun buildEncryptedPreferences() = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) + } catch (error: GeneralSecurityException) { + recoverEncryptedPreferences(error) + } catch (error: RuntimeException) { + recoverEncryptedPreferences(error) + } + + private fun recoverEncryptedPreferences(error: Throwable) = try { + Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error) + context.deleteSharedPreferences("harheimertc_home_layout") + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "harheimertc_home_layout", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (retryError: Throwable) { + Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError) + throw retryError } fun getSections(): List? { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 66383c7..d0e334b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.harheimertc.BuildConfig import de.harheimertc.R @@ -42,14 +43,17 @@ private enum class MenuSection { TRAINING, NEWSLETTER, INTERN, + CMS, } private data class MenuTarget(val label: String, val route: String) +private const val LOGOUT_ROUTE = "__logout__" @Composable fun AppNavigationHeader( selectedRoute: String?, onNavigate: (String) -> Unit, + onLogout: () -> Unit = {}, webTabletNavigation: Boolean = false, navigationState: NavigationUiState = NavigationUiState(), ) { @@ -61,9 +65,9 @@ fun AppNavigationHeader( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (webTabletNavigation) { - WebTabletNavigation(selectedRoute, onNavigate, navigationState) + WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState) } else { - CompactNavigation(selectedRoute, onNavigate, navigationState) + CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState) } } } @@ -72,110 +76,53 @@ fun AppNavigationHeader( private fun CompactNavigation( selectedRoute: String?, onNavigate: (String) -> Unit, + onLogout: () -> Unit, navigationState: NavigationUiState = NavigationUiState(), ) { val routeSection = menuSection(selectedRoute) val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } val section = routeSection ?: sectionOverride.value val subItems = submenu(section, navigationState) - var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } - val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) } + val mainScroll = rememberScrollState() val subScroll = rememberScrollState() - val cmsSubScroll = rememberScrollState() - BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.horizontalScroll(mainScroll), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null } - CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN } - CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN } - CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING } - CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null } - CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN } - CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER } - if (navigationState.showGallery) { - CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN } - } - if (navigationState.loggedIn) { - CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN } - } - CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null } - if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) { - CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN } - } - } + BrandRow( + loggedIn = navigationState.loggedIn, + onLogin = { onNavigate(Destinations.Login.route) }, + onOpenCms = { sectionOverride.value = MenuSection.CMS }, + ) - 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 } + } else { + CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null } } + CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null } } - val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } - val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList() - if (cmsChildren.any { it.route == selectedRoute }) { - cmsExpanded.value = true - } - - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(subScroll) - .padding(top = 3.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - subItems.forEachIndexed { idx, item -> - if (idx == cmsIndex) { - SubLink(item.label, item.route == selectedRoute) { - cmsExpanded.value = !cmsExpanded.value + ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) { + subItems.forEach { item -> + SubLink(item.label, item.route == selectedRoute) { + if (item.route == LOGOUT_ROUTE) { + onLogout() + } else { + onNavigate(item.route) } - } else if (idx > cmsIndex && cmsIndex >= 0) { - // CMS children are rendered below when expanded. - } else { - SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) } } } } - if (subItems.isNotEmpty()) { - ScrollHintRow(subScroll) - } - - if (cmsExpanded.value && cmsChildren.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(cmsSubScroll) - .padding(top = 6.dp, bottom = 3.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - cmsChildren.forEach { child -> - SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) } - } - } - ScrollHintRow(cmsSubScroll) - } } @Composable @@ -206,12 +153,13 @@ private fun CompactSectionLink( private fun WebTabletNavigation( selectedRoute: String?, onNavigate: (String) -> Unit, + onLogout: () -> Unit, navigationState: NavigationUiState, ) { - val section = menuSection(selectedRoute) - var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } - // Helper that closes the CMS submenu when navigating away - val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) } + val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } + val section = menuSection(selectedRoute) ?: sectionOverride.value + val subScroll = rememberScrollState() + Row(verticalAlignment = Alignment.CenterVertically) { Brand() Spacer(Modifier.width(16.dp)) @@ -220,74 +168,56 @@ 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 = { onNavigate(Destinations.Home.route) }) + MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) }) + MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) }) + MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) }) + MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) }) if (navigationState.showGallery) { MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) } MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) }) if (navigationState.loggedIn) { - MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) }) + MainLink("Intern", section == MenuSection.INTERN, onClick = { sectionOverride.value = MenuSection.INTERN }) } - MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) }) - TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) } + MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) }) } - } - val subItems = submenu(section, navigationState) - // determine CMS parent index and children - val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } - val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList() - if (cmsChildren.any { it.route == selectedRoute }) { - cmsExpanded.value = true - } - // First row: render all subitems but do NOT render CMS children here - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(top = 3.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - subItems.forEachIndexed { idx, item -> - if (idx == cmsIndex) { - // CMS parent toggle - SubLink(item.label, item.route == selectedRoute) { - cmsExpanded.value = !cmsExpanded.value - } - } 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) } - } + Spacer(Modifier.width(12.dp)) + if (navigationState.loggedIn) { + TextButton(onClick = { sectionOverride.value = MenuSection.CMS }) { Text("CMS", color = Color.White) } + } else { + TextButton(onClick = { 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, + onOpenCms: () -> Unit, +) { Row(verticalAlignment = Alignment.CenterVertically) { Brand() Spacer(Modifier.weight(1f)) - TextButton(onClick = onLogin) { Text("Login", color = Color.White) } + if (loggedIn) { + TextButton(onClick = onOpenCms) { Text("CMS", color = Color.White) } + } else { + TextButton(onClick = onLogin) { Text("Login", color = Color.White) } + } } } @@ -367,25 +297,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 +372,14 @@ 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.MemberApi.route -> MenuSection.INTERN + Destinations.CmsStartseite.route, Destinations.CmsInhalte.route, Destinations.CmsVereinsmeisterschaften.route, @@ -447,7 +390,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,23 +408,27 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List listOf( MenuTarget("Übersicht", Destinations.Mannschaften.route), ) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf( MenuTarget("Spielpläne", Destinations.Spielplan.route), MenuTarget("Spielsysteme", Destinations.Spielsysteme.route), ) + MenuSection.TRAINING -> listOf( MenuTarget("Trainingszeiten", Destinations.Training.route), MenuTarget("Trainer", Destinations.Trainer.route), MenuTarget("Anfänger", Destinations.Anfaenger.route), MenuTarget("TT-Regeln", Destinations.Regeln.route), ) + MenuSection.NEWSLETTER -> listOf( MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route), MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route), MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route), ) + MenuSection.INTERN -> buildList { add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route)) @@ -488,19 +436,23 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List buildList { + 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("Kontaktanfragen", Destinations.CmsContactRequests.route)) + add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route)) + add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route)) + add(MenuTarget("Ausloggen", LOGOUT_ROUTE)) + } + null -> emptyList() } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index e5d778f..bd03d63 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -39,6 +39,13 @@ fun NavGraph( AppNavigationHeader( selectedRoute = currentRoute, onNavigate = navController::navigateTopLevel, + onLogout = { + navigationViewModel.logout { + navController.navigate(Destinations.Home.route) { + launchSingleTop = true + } + } + }, webTabletNavigation = true, navigationState = navigationState, ) @@ -52,6 +59,8 @@ fun NavGraph( de.harheimertc.ui.screens.home.HomeScreen( navController = navController, showNavigationHeader = !persistentNavigation, + navigationViewModel = navigationViewModel, + viewModel = hiltViewModel(), ) } composable(Destinations.VereinAbout.route) { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt index 4e7741c..c1f1cc1 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.navigation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.Mannschaft @@ -30,6 +31,7 @@ class NavigationViewModel @Inject constructor( private val mannschaftenRepository: MannschaftenRepository, private val galleryRepository: GalleryRepository, private val loginRepository: LoginRepository, + private val authRepository: AuthRepository, ) : ViewModel() { private val _state = MutableStateFlow(NavigationUiState()) val state: StateFlow = _state @@ -44,10 +46,11 @@ 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() _state.value = NavigationUiState( teams = teams.await(), hasGalleryImages = gallery.await(), - loggedIn = status.isLoggedIn, + loggedIn = hasStoredSession || status.isLoggedIn, roles = (status.roles + status.user?.roles.orEmpty()).toSet(), ) } @@ -56,10 +59,22 @@ class NavigationViewModel @Inject constructor( fun refreshSession() { viewModelScope.launch { val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) + val hasStoredSession = !authRepository.getToken().isNullOrBlank() _state.value = _state.value.copy( - loggedIn = status.isLoggedIn, + loggedIn = hasStoredSession || status.isLoggedIn, roles = (status.roles + status.user?.roles.orEmpty()).toSet(), ) } } + + fun logout(onComplete: () -> Unit = {}) { + viewModelScope.launch { + loginRepository.logout() + _state.value = _state.value.copy( + loggedIn = false, + roles = emptySet(), + ) + onComplete() + } + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt index 409eab2..0557bcd 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel import de.harheimertc.ui.navigation.NavigationViewModel import androidx.navigation.NavController import coil.compose.AsyncImage @@ -77,9 +76,9 @@ import java.util.Locale fun HomeScreen( navController: NavController, showNavigationHeader: Boolean = true, - viewModel: HomeViewModel = hiltViewModel(), + navigationViewModel: NavigationViewModel, + viewModel: HomeViewModel, ) { - val navigationViewModel: NavigationViewModel = hiltViewModel() val navigationState by navigationViewModel.state.collectAsState() val state by viewModel.state.collectAsState() var selectedNews by remember { mutableStateOf(null) } @@ -107,6 +106,13 @@ fun HomeScreen( AppNavigationHeader( selectedRoute = Destinations.Home.route, onNavigate = navController::navigate, + onLogout = { + navigationViewModel.logout { + navController.navigate(Destinations.Home.route) { + launchSingleTop = true + } + } + }, navigationState = navigationState, ) } diff --git a/plugins/auth-sync.client.js b/plugins/auth-sync.client.js new file mode 100644 index 0000000..00469e4 --- /dev/null +++ b/plugins/auth-sync.client.js @@ -0,0 +1,25 @@ +export default defineNuxtPlugin((nuxtApp) => { + const authStore = useAuthStore() + + const syncAuthState = async () => { + await authStore.checkAuth() + } + + nuxtApp.hook('app:mounted', () => { + syncAuthState() + }) + + if (typeof window !== 'undefined') { + window.addEventListener('pageshow', (event) => { + if (event.persisted) { + syncAuthState() + } + }) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + syncAuthState() + } + }) + } +}) \ No newline at end of file diff --git a/temp/device-35433-after-intern-fix.png b/temp/device-35433-after-intern-fix.png new file mode 100644 index 0000000..7da1175 Binary files /dev/null and b/temp/device-35433-after-intern-fix.png differ diff --git a/temp/device-35433-cms-after-split-2.png b/temp/device-35433-cms-after-split-2.png new file mode 100644 index 0000000..3d9d41e Binary files /dev/null and b/temp/device-35433-cms-after-split-2.png differ diff --git a/temp/device-35433-cms-after-split.png b/temp/device-35433-cms-after-split.png new file mode 100644 index 0000000..97b86ce Binary files /dev/null and b/temp/device-35433-cms-after-split.png differ diff --git a/temp/device-35433-cms-open.png b/temp/device-35433-cms-open.png new file mode 100644 index 0000000..829703b Binary files /dev/null and b/temp/device-35433-cms-open.png differ diff --git a/temp/device-35433-home.png b/temp/device-35433-home.png new file mode 100644 index 0000000..ba3aef7 Binary files /dev/null and b/temp/device-35433-home.png differ diff --git a/temp/device-35433-intern-after-split.png b/temp/device-35433-intern-after-split.png new file mode 100644 index 0000000..aa849f3 Binary files /dev/null and b/temp/device-35433-intern-after-split.png differ diff --git a/temp/device-37165-arrow-check.png b/temp/device-37165-arrow-check.png new file mode 100644 index 0000000..e69de29 diff --git a/temp/device-38281-after-fix.png b/temp/device-38281-after-fix.png new file mode 100644 index 0000000..793954d Binary files /dev/null and b/temp/device-38281-after-fix.png differ diff --git a/temp/device-38281-cms-2.png b/temp/device-38281-cms-2.png new file mode 100644 index 0000000..793954d Binary files /dev/null and b/temp/device-38281-cms-2.png differ diff --git a/temp/device-38281-cms-open.png b/temp/device-38281-cms-open.png new file mode 100644 index 0000000..2226db3 Binary files /dev/null and b/temp/device-38281-cms-open.png differ diff --git a/temp/device-38281-cms.png b/temp/device-38281-cms.png new file mode 100644 index 0000000..793954d Binary files /dev/null and b/temp/device-38281-cms.png differ diff --git a/temp/device-38281.png b/temp/device-38281.png new file mode 100644 index 0000000..97993c1 Binary files /dev/null and b/temp/device-38281.png differ diff --git a/temp/device-43477-arrow-check.png b/temp/device-43477-arrow-check.png new file mode 100644 index 0000000..3c52bb2 Binary files /dev/null and b/temp/device-43477-arrow-check.png differ diff --git a/temp/device-43477-intern-inline.png b/temp/device-43477-intern-inline.png new file mode 100644 index 0000000..9a2145e Binary files /dev/null and b/temp/device-43477-intern-inline.png differ diff --git a/temp/ui-35433.xml b/temp/ui-35433.xml new file mode 100644 index 0000000..d9249c6 --- /dev/null +++ b/temp/ui-35433.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/temp/ui-cms.xml b/temp/ui-cms.xml new file mode 100644 index 0000000..d9249c6 --- /dev/null +++ b/temp/ui-cms.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/temp/ui-current.xml b/temp/ui-current.xml new file mode 100644 index 0000000..117bf9a --- /dev/null +++ b/temp/ui-current.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/temp/ui.xml b/temp/ui.xml new file mode 100644 index 0000000..d9249c6 --- /dev/null +++ b/temp/ui.xml @@ -0,0 +1 @@ + \ No newline at end of file