diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md index 7dc6459..618d047 100644 --- a/ANDROID_KOTLIN_PLAN.md +++ b/ANDROID_KOTLIN_PLAN.md @@ -41,7 +41,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web [x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton [x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen [x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts) - [ ] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen + [x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen [x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren [x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation [x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden @@ -69,35 +69,34 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - [x] `/training/trainer` - [x] `/training/anfaenger` - [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf - [ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren - - [ ] `/anlagen` - - [ ] `/impressum` - - [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend` - [ ] 10b. Newsletter-Screens portieren - - [ ] `/newsletter/subscribe` - - [ ] `/newsletter/unsubscribe` - - [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed` + [x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren + - [x] `/impressum` + - [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend` + [x] 10b. Newsletter-Screens portieren + - [x] `/newsletter/subscribe` + - [x] `/newsletter/unsubscribe` + - [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed` [x] 10c. Auth-Screens portieren - [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung - [x] `/registrieren` - [x] `/passwort-vergessen` - [ ] 10d. Mitgliederbereich portieren - - [ ] `/mitgliederbereich`: Übersicht - - [ ] `/mitgliederbereich/mitglieder` - - [ ] `/mitgliederbereich/news` - - [ ] `/mitgliederbereich/profil` - - [ ] `/mitgliederbereich/api` - [ ] 10e. CMS-Screens nach Rollenberechtigung portieren - - [ ] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften` - - [ ] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen` - - [ ] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer` + [x] 10d. Mitgliederbereich portieren + - [x] `/mitgliederbereich`: Übersicht + - [x] `/mitgliederbereich/mitglieder` + - [x] `/mitgliederbereich/news` + - [x] `/mitgliederbereich/profil` + - [x] `/mitgliederbereich/api` + [x] 10e. CMS-Screens nach Rollenberechtigung portieren + - [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften` + - [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen` + - [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer` [ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh) - [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung - [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt - [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider` - [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor - [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token - - [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche + - [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt) - [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern - [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen [x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences) @@ -109,10 +108,19 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen - [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen - [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden - [ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort + [x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort + - [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden + - [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen + - [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern + - [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen [ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig) - [ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge - [ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung + [x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge + - [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt + - [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ + - [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird + [x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung + - [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt + - [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld [ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie [ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check [ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen @@ -135,10 +143,10 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web 6) Nächste Aktionen (sofort) - Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel. -- Passkey-Anmeldung über Android Credential Manager anbinden. +- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen. - Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren. - Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen. -- Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der verwendeten Backend-Endpunkte ergänzen. +- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen. 7) Umsetzungsprotokoll - 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt. @@ -159,6 +167,12 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln. - 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar. - 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert. +- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen. +- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens. +- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens. +- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt. +- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat. +- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt. 8) Android-Testumgebungen - Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 4871544..99fc463 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -94,6 +94,10 @@ dependencies { implementation("com.squareup.retrofit2:converter-moshi:2.9.0") implementation("com.squareup.moshi:moshi-kotlin:1.15.1") + // Passkeys / Credential Manager + implementation("androidx.credentials:credentials:1.6.0") + implementation("androidx.credentials:credentials-play-services-auth:1.6.0") + // Coil implementation("io.coil-kt:coil-compose:2.4.0") diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 630d5e2..ea77b1a 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -3,12 +3,14 @@ package de.harheimertc.data import com.squareup.moshi.Json import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Query import retrofit2.http.Url import retrofit2.http.Streaming import okhttp3.ResponseBody +import okhttp3.RequestBody data class ContactRequest(val name: String, val email: String, val message: String) data class ContactResponse(val ok: Boolean, val id: String? = null) @@ -77,6 +79,23 @@ data class NewsDto( val title: String = "", val content: String = "", val created: String? = null, + val updated: String? = null, + val author: String? = null, + val isPublic: Boolean = false, + val isHidden: Boolean = false, + val expiresAt: String? = null, +) +data class NewsResponse( + val success: Boolean = false, + val news: List = emptyList(), +) +data class NewsSaveRequest( + val id: Int? = null, + val title: String, + val content: String, + val isPublic: Boolean = false, + val isHidden: Boolean = false, + val expiresAt: String? = null, ) data class PublicGalleryImageDto( val filename: String = "", @@ -137,6 +156,87 @@ data class AuthStatusResponse( ) data class ResetPasswordRequest(val email: String) data class AuthMessageResponse(val success: Boolean = false, val message: String? = null) +data class PasskeyAuthenticationOptionsRequest( + val email: String? = null, + val client: String = "android", +) +data class PasskeyRegistrationOptionsRequest( + val preferredAuthenticatorType: String? = null, + val client: String = "android", +) +data class PasskeyDto( + val id: String = "", + val credentialId: String = "", + val createdAt: String? = null, + val lastUsedAt: String? = null, + val name: String = "", +) +data class PasskeysResponse( + val success: Boolean = false, + val passkeys: List = emptyList(), +) +data class RemovePasskeyRequest(val credentialId: String) +data class ProfileVisibilityDto( + val showEmail: Boolean = true, + val showPhone: Boolean = true, + val showAddress: Boolean = false, + val showBirthday: Boolean = true, +) +data class ProfileUserDto( + val id: String? = null, + val name: String = "", + val email: String = "", + val phone: String = "", + val geburtsdatum: String = "", + val visibility: ProfileVisibilityDto = ProfileVisibilityDto(), + val roles: List = emptyList(), + val role: String? = null, +) +data class ProfileResponse( + val success: Boolean = false, + val message: String? = null, + val user: ProfileUserDto? = null, +) +data class ProfileUpdateRequest( + val name: String, + val email: String, + val phone: String? = null, + val geburtsdatum: String? = null, + val visibility: ProfileVisibilityDto, + val currentPassword: String? = null, + val newPassword: String? = null, +) +data class BirthdayDto( + val name: String = "", + val dayMonth: String = "", + val inDays: Int = 0, +) +data class BirthdaysResponse( + val success: Boolean = false, + val birthdays: List = emptyList(), +) +data class MemberDto( + val id: String? = null, + val name: String = "", + val firstName: String = "", + val lastName: String = "", + val email: String? = null, + val phone: String? = null, + val address: String? = null, + val birthday: String? = null, + val geburtsdatum: String? = null, + val source: String = "", + val notes: String = "", + val hasLogin: Boolean = false, + val editable: Boolean = false, + val isMannschaftsspieler: Boolean = false, + val hasHallKey: Boolean = false, + val loginRoles: List = emptyList(), +) +data class MembersResponse( + val success: Boolean = false, + val members: List = emptyList(), +) data class RegistrationVisibility(val showBirthday: Boolean) data class RegistrationRequest( val name: String, @@ -190,6 +290,21 @@ data class VorstandDto( val sportwart: BoardMemberDto = BoardMemberDto(), val jugendwart: BoardMemberDto = BoardMemberDto(), ) +data class VereinDto( + val name: String = "", + val strasse: String = "", + val plz: String = "", + val ort: String = "", + val useVorsitzenderAddress: Boolean = false, +) +data class WebsiteResponsibleDto( + val vorname: String = "", + val nachname: String = "", + val email: String = "", +) +data class WebsiteDto( + val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(), +) data class SatzungDto( val pdfUrl: String = "", val content: String = "", @@ -214,9 +329,81 @@ data class SeitenDto( data class ConfigResponse( val training: TrainingDto = TrainingDto(), val trainer: List = emptyList(), + val verein: VereinDto = VereinDto(), val vorstand: VorstandDto = VorstandDto(), + val website: WebsiteDto = WebsiteDto(), val seiten: SeitenDto = SeitenDto(), ) +data class CmsUserDto( + val id: String = "", + val email: String? = null, + val name: String = "", + val roles: List = emptyList(), + val role: String? = null, + val phone: String = "", + val active: Boolean = true, + val created: String? = null, + val lastLogin: String? = null, +) +data class CmsUsersResponse(val users: List = emptyList()) +data class ContactRequestDto( + val id: String = "", + val name: String = "", + val email: String = "", + val phone: String? = null, + val message: String = "", + val status: String = "", + val createdAt: String? = null, + val repliedAt: String? = null, +) +data class NewsletterDto( + val id: String = "", + val subject: String = "", + val title: String = "", + val createdAt: String? = null, + val sentAt: String? = null, + val status: String? = null, +) +data class NewsletterListResponse( + val success: Boolean = false, + val newsletters: List = emptyList(), +) +data class NewsletterGroupDto( + val id: String = "", + val name: String = "", + val description: String = "", + val subscribers: List = emptyList(), + val createdAt: String? = null, +) +data class NewsletterGroupsResponse( + val success: Boolean = false, + val groups: List = emptyList(), +) +data class NewsletterSubscriptionRequest( + val groupId: String, + val email: String, + val name: String? = null, +) +data class PasswordResetStepDto( + val ts: String? = null, + val step: String = "", + val status: String = "", + val reason: String? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) +data class PasswordResetAttemptDto( + val requestId: String = "", + val startedAt: String? = null, + val emailMasked: String? = null, + val ip: String? = null, + val failed: Boolean = false, + val steps: List = emptyList(), +) +data class PasswordResetDiagnosticsResponse( + val retentionHours: Int = 0, + val attempts: List = emptyList(), +) interface ApiService { @POST("/api/contact") @@ -243,6 +430,15 @@ interface ApiService { @GET("/api/news-public") suspend fun publicNews(): Response + @GET("/api/news") + suspend fun memberNews(): Response + + @POST("/api/news") + suspend fun saveNews(@Body request: NewsSaveRequest): Response + + @DELETE("/api/news") + suspend fun deleteNews(@Query("id") id: Int): Response + @GET("/api/mannschaften") suspend fun mannschaften(@Query("season") season: String? = null): Response @@ -279,4 +475,61 @@ interface ApiService { @POST("/api/auth/register") suspend fun register(@Body request: RegistrationRequest): Response + + @POST("/api/auth/passkeys/authentication-options") + suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response + + @POST("/api/auth/passkeys/login") + suspend fun passkeyLogin(@Body request: RequestBody): Response + + @GET("/api/auth/passkeys/list") + suspend fun passkeys(): Response + + @POST("/api/auth/passkeys/registration-options") + suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response + + @POST("/api/auth/passkeys/register") + suspend fun registerPasskey(@Body request: RequestBody): Response + + @POST("/api/auth/passkeys/remove") + suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response + + @GET("/api/profile") + suspend fun profile(): Response + + @retrofit2.http.PUT("/api/profile") + suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response + + @GET("/api/birthdays") + suspend fun birthdays(): Response + + @GET("/api/members") + suspend fun members(): Response + + @GET("/api/cms/users/list") + suspend fun cmsUsers(): Response + + @GET("/api/cms/contact-requests") + suspend fun contactRequests(): Response> + + @GET("/api/newsletter/list") + suspend fun newsletters(): Response + + @GET("/api/newsletter/groups/list") + suspend fun newsletterGroups(): Response + + @GET("/api/newsletter/groups/public-list") + suspend fun publicNewsletterGroups(): Response + + @POST("/api/newsletter/subscribe") + suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response + + @POST("/api/newsletter/unsubscribe-by-email") + suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response + + @GET("/api/newsletter/confirm") + suspend fun confirmNewsletter(@Query("token") token: String): Response + + @GET("/api/cms/password-reset-diagnostics") + suspend fun passwordResetDiagnostics(): Response } diff --git a/android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt b/android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt new file mode 100644 index 0000000..4fd3e66 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt @@ -0,0 +1,7 @@ +package de.harheimertc.data + +import okhttp3.MediaType.Companion.toMediaType + +object MediaTypes { + val json = "application/json; charset=utf-8".toMediaType() +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt new file mode 100644 index 0000000..8b6380c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -0,0 +1,48 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.CmsUsersResponse +import de.harheimertc.data.ConfigResponse +import de.harheimertc.data.ContactRequestDto +import de.harheimertc.data.NewsletterGroupsResponse +import de.harheimertc.data.NewsletterListResponse +import de.harheimertc.data.PasswordResetDiagnosticsResponse +import javax.inject.Inject + +class CmsRepository @Inject constructor(private val api: ApiService) { + suspend fun config(): Result = runCatching { + val response = api.config() + if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun users(): Result = runCatching { + val response = api.cmsUsers() + if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun contactRequests(): Result> = runCatching { + val response = api.contactRequests() + if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.") + response.body() ?: emptyList() + } + + suspend fun newsletters(): Result = runCatching { + val response = api.newsletters() + if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun newsletterGroups(): Result = runCatching { + val response = api.newsletterGroups() + if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun passwordResetDiagnostics(): Result = runCatching { + val response = api.passwordResetDiagnostics() + if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt new file mode 100644 index 0000000..9c9778d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt @@ -0,0 +1,38 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.BirthdaysResponse +import de.harheimertc.data.MembersResponse +import de.harheimertc.data.NewsResponse +import de.harheimertc.data.NewsSaveRequest +import javax.inject.Inject + +class MemberAreaRepository @Inject constructor(private val api: ApiService) { + suspend fun birthdays(): Result = runCatching { + val response = api.birthdays() + if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun members(): Result = runCatching { + val response = api.members() + if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun news(): Result = runCatching { + val response = api.memberNews() + if (!response.isSuccessful) error("News konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun saveNews(request: NewsSaveRequest): Result = runCatching { + val response = api.saveNews(request) + if (!response.isSuccessful) error("News konnten nicht gespeichert werden.") + } + + suspend fun deleteNews(id: Int): Result = runCatching { + val response = api.deleteNews(id) + if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt new file mode 100644 index 0000000..9bada4d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt @@ -0,0 +1,33 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.AuthMessageResponse +import de.harheimertc.data.NewsletterGroupsResponse +import de.harheimertc.data.NewsletterSubscriptionRequest +import javax.inject.Inject + +class NewsletterRepository @Inject constructor(private val api: ApiService) { + suspend fun groups(): Result = runCatching { + val response = api.publicNewsletterGroups() + if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun subscribe(groupId: String, email: String, name: String?): Result = runCatching { + val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty())) + if (!response.isSuccessful) error("Newsletter-Anmeldung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Eine Bestätigungsmail wurde versendet.") + } + + suspend fun unsubscribe(groupId: String, email: String): Result = runCatching { + val response = api.unsubscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim())) + if (!response.isSuccessful) error("Newsletter-Abmeldung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Sie wurden abgemeldet.") + } + + suspend fun confirm(token: String): Result = runCatching { + val response = api.confirmNewsletter(token) + if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt new file mode 100644 index 0000000..11d5a57 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt @@ -0,0 +1,114 @@ +package de.harheimertc.repositories + +import android.content.Context +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCancellationException +import de.harheimertc.data.ApiService +import de.harheimertc.data.AuthMessageResponse +import de.harheimertc.data.LoginResponse +import de.harheimertc.data.MediaTypes +import de.harheimertc.data.PasskeyAuthenticationOptionsRequest +import de.harheimertc.data.PasskeyRegistrationOptionsRequest +import de.harheimertc.data.PasskeysResponse +import de.harheimertc.data.RemovePasskeyRequest +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PasskeyRepository @Inject constructor( + private val api: ApiService, + private val authRepository: AuthRepository, +) { + suspend fun login(context: Context, email: String?): Result = runCatching { + val optionsResponse = api.passkeyAuthenticationOptions( + PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)), + ) + if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.") + val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") + ?: error("Der Server hat keine Passkey-Optionen geliefert.") + + 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 + }.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.") + + suspend fun list(): Result = runCatching { + val response = api.passkeys() + if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } + + suspend fun add(context: Context, name: String = "Android-App"): Result = runCatching { + val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) + if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") + val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") + ?: error("Der Server hat keine Passkey-Optionen geliefert.") + + 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") + }.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.") + + suspend fun remove(credentialId: String): Result = runCatching { + val response = api.removePasskey(RemovePasskeyRequest(credentialId)) + if (!response.isSuccessful) error("Passkey konnte nicht entfernt werden.") + response.body() ?: error("Leere Antwort") + } + + private fun String.extractJsonObject(key: String): String? { + val root = JSONObject(this) + return root.optJSONObject(key)?.toString() + } + + private fun Result.recoverCredentialCancellation(message: String): Result = + recoverCatching { error -> + when (error) { + is GetCredentialCancellationException, + is CreateCredentialCancellationException -> throw IllegalStateException(message) + else -> throw error + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt new file mode 100644 index 0000000..d5ee5ca --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt @@ -0,0 +1,22 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.ProfileResponse +import de.harheimertc.data.ProfileUpdateRequest +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProfileRepository @Inject constructor(private val api: ApiService) { + suspend fun load(): Result = runCatching { + val response = api.profile() + if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } + + suspend fun save(request: ProfileUpdateRequest): Result = runCatching { + val response = api.updateProfile(request) + if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") + response.body() ?: error("Leere Antwort") + } +} 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 d7351ee..b60713b 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 @@ -228,6 +228,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.Satzung.route, Destinations.Vereinsmeisterschaften.route, Destinations.Links.route, + Destinations.Impressum.route, Destinations.Gallery.route -> MenuSection.VEREIN Destinations.Mannschaften.route, @@ -240,14 +241,25 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.Regeln.route -> MenuSection.TRAINING Destinations.NewsletterSubscribe.route, - Destinations.NewsletterUnsubscribe.route -> MenuSection.NEWSLETTER + Destinations.NewsletterUnsubscribe.route, + Destinations.NewsletterConfirm.route, + Destinations.NewsletterConfirmed.route, + Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER Destinations.MemberArea.route, Destinations.Members.route, Destinations.MemberNews.route, Destinations.Profile.route, Destinations.MemberApi.route, + Destinations.CmsStartseite.route, + Destinations.CmsInhalte.route, + Destinations.CmsVereinsmeisterschaften.route, + Destinations.CmsSportbetrieb.route, + Destinations.CmsMitgliederverwaltung.route, Destinations.CmsNewsletter.route, Destinations.CmsContactRequests.route, + Destinations.CmsEinstellungen.route, + Destinations.CmsBenutzer.route, + Destinations.CmsPasswordResetDiagnostics.route, Destinations.Cms.route -> MenuSection.INTERN else -> null }.let { section -> @@ -263,6 +275,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List listOf( MenuTarget("Übersicht", Destinations.Mannschaften.route), @@ -279,6 +292,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List 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)) @@ -286,9 +300,17 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List emptyList() } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt new file mode 100644 index 0000000..3769bea --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt @@ -0,0 +1,56 @@ +package de.harheimertc.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.VisualTransformation + +@Composable +fun ValidatedTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier.fillMaxWidth(), + error: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + singleLine: Boolean = true, + minLines: Int = 1, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + singleLine = singleLine, + minLines = minLines, + modifier = modifier, + ) +} + +@Composable +fun FormMessages(error: String?, message: String?) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + message?.let { Text(it, color = Color(0xFF166534)) } +} + +internal fun isValidEmail(value: String): Boolean { + val trimmed = value.trim() + return trimmed.length in 5..254 && + trimmed.count { it == '@' } == 1 && + trimmed.substringBefore('@').isNotBlank() && + trimmed.substringAfter('@').contains('.') && + !trimmed.any(Char::isWhitespace) +} + +internal fun isValidIsoDate(value: String): Boolean = + value.trim().matches(Regex("\\d{4}-\\d{2}-\\d{2}")) + diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt new file mode 100644 index 0000000..30da07d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt @@ -0,0 +1,31 @@ +package de.harheimertc.ui.components + +import android.text.method.LinkMovementMethod +import android.widget.TextView +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat + +@Composable +fun RichText( + html: String, + modifier: Modifier = Modifier.fillMaxWidth(), +) { + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + textSize = 17f + setTextColor(android.graphics.Color.rgb(63, 63, 70)) + movementMethod = LinkMovementMethod.getInstance() + setLineSpacing(0f, 1.2f) + } + }, + update = { textView -> + textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) + }, + ) +} + diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt index 3ce736e..918abbb 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt @@ -8,10 +8,14 @@ sealed class Destinations(val route: String) { object Satzung : Destinations("verein/satzung") object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften") 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 MannschaftLegacyDetail : Destinations("mannschaft/{slug}") { + fun create(slug: String): String = "mannschaft/$slug" + } object Termine : Destinations("termine") object Spielplan : Destinations("spielplan") object Spielsysteme : Destinations("mannschaften/spielsysteme") @@ -22,6 +26,9 @@ sealed class Destinations(val route: String) { object Gallery : Destinations("gallery") object NewsletterSubscribe : Destinations("newsletter/subscribe") object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe") + object NewsletterConfirm : Destinations("newsletter/confirm") + object NewsletterConfirmed : Destinations("newsletter/confirmed") + object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed") object Contact : Destinations("contact") object Membership : Destinations("membership") object Login : Destinations("login") @@ -32,7 +39,15 @@ sealed class Destinations(val route: String) { object MemberNews : Destinations("intern/news") object Profile : Destinations("intern/profil") object MemberApi : Destinations("intern/api") + object CmsStartseite : Destinations("cms/startseite") + object CmsInhalte : Destinations("cms/inhalte") + object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften") + object CmsSportbetrieb : Destinations("cms/sportbetrieb") + object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung") object CmsNewsletter : Destinations("cms/newsletter") object CmsContactRequests : Destinations("cms/kontaktanfragen") + object CmsEinstellungen : Destinations("cms/einstellungen") + object CmsBenutzer : Destinations("cms/benutzer") + object CmsPasswordResetDiagnostics : Destinations("cms/passwort-reset-diagnose") object Cms : Destinations("cms") } 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 07421f6..7c6bd11 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 @@ -15,7 +15,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import de.harheimertc.ui.components.AppNavigationHeader -import de.harheimertc.ui.components.PendingPage @Composable fun NavGraph( @@ -90,12 +89,27 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable(Destinations.Impressum.route) { + de.harheimertc.ui.screens.publicpages.ImpressumScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } composable(Destinations.Mannschaften.route) { de.harheimertc.ui.screens.mannschaften.MannschaftenScreen( navController = navController, showBackNavigation = !persistentNavigation, ) } + composable("mannschaften/herren") { + de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation) + } + composable("mannschaften/damen") { + de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation) + } + composable("mannschaften/jugend") { + de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation) + } composable(Destinations.MannschaftDetail.route) { entry -> de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( slug = entry.arguments?.getString("slug").orEmpty(), @@ -103,6 +117,13 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable(Destinations.MannschaftLegacyDetail.route) { entry -> + de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( + slug = entry.arguments?.getString("slug").orEmpty(), + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } composable(Destinations.Termine.route) { de.harheimertc.ui.screens.termine.TermineScreen( navController = navController, @@ -121,6 +142,9 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable("spielsysteme") { + de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation) + } composable(Destinations.Training.route) { de.harheimertc.ui.screens.training.TrainingScreen( navController = navController, @@ -145,14 +169,48 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable("tt-regeln") { + de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation) + } + composable("verein/tt-regeln") { + de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation) + } composable(Destinations.Gallery.route) { de.harheimertc.ui.screens.gallery.GalleryScreen() } + composable("galerie") { + de.harheimertc.ui.screens.gallery.GalleryScreen() + } composable(Destinations.NewsletterSubscribe.route) { - PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation) + de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.NewsletterUnsubscribe.route) { - PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation) + de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } + composable(Destinations.NewsletterConfirm.route) { + de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + token = null, + ) + } + composable(Destinations.NewsletterConfirmed.route) { + de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } + composable(Destinations.NewsletterUnsubscribed.route) { + de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.Contact.route) { de.harheimertc.ui.screens.contact.ContactScreen() @@ -181,29 +239,77 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable("ueber-uns") { + de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation) + } + composable("geschichte") { + de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation) + } + composable("satzung") { + de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation) + } composable(Destinations.MemberArea.route) { - PendingPage(navController, "Intern", "/mitgliederbereich", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MemberAreaScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.Members.route) { - PendingPage(navController, "Mitgliederliste", "/mitgliederbereich/mitglieder", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MembersScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.MemberNews.route) { - PendingPage(navController, "News", "/mitgliederbereich/news", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MemberNewsScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.Profile.route) { - PendingPage(navController, "Mein Profil", "/mitgliederbereich/profil", !persistentNavigation) + de.harheimertc.ui.screens.profile.ProfileScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.MemberApi.route) { - PendingPage(navController, "API-Dokumentation", "/mitgliederbereich/api", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MemberApiScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } + composable(Destinations.CmsStartseite.route) { + de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsInhalte.route) { + de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsVereinsmeisterschaften.route) { + de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsSportbetrieb.route) { + de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsMitgliederverwaltung.route) { + de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation) } composable(Destinations.CmsNewsletter.route) { - PendingPage(navController, "Newsletter", "/cms/newsletter", !persistentNavigation) + de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation) } composable(Destinations.CmsContactRequests.route) { - PendingPage(navController, "Kontaktanfragen", "/cms/kontaktanfragen", !persistentNavigation) + de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsEinstellungen.route) { + de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsBenutzer.route) { + de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsPasswordResetDiagnostics.route) { + de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation) } composable(Destinations.Cms.route) { - PendingPage(navController, "CMS", "/cms", !persistentNavigation) + de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt new file mode 100644 index 0000000..febeaa9 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -0,0 +1,305 @@ +package de.harheimertc.ui.screens.cms + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.ui.Modifier +import androidx.compose.ui.graphics.Color +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.data.CmsUserDto +import de.harheimertc.data.ConfigResponse +import de.harheimertc.data.ContactRequestDto +import de.harheimertc.data.NewsletterDto +import de.harheimertc.data.NewsletterGroupDto +import de.harheimertc.data.PasswordResetAttemptDto +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 + +@Composable +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) } + item { CmsSummaryGrid(navController, state) } + } +} + +@Composable +fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Startseite", "Öffentliche Startseiteninhalte", state.config) { + InfoRow("Öffentliche News", "${state.newsletters.count { it.sentAt != null }} versendete Newsletter") + InfoRow("Kontaktanfragen", "${state.contactRequests.size} Einträge") + } +} + +@Composable +fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte", state.config) { config -> + InfoRow("Über uns", textState(config.seiten.ueberUns)) + InfoRow("Geschichte", textState(config.seiten.geschichte)) + InfoRow("Satzung", if (config.seiten.satzung.pdfUrl.isNotBlank()) config.seiten.satzung.pdfUrl else textState(config.seiten.satzung.content)) + InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen") + } +} + +@Composable +fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Vereinsmeisterschaften", "CSV- und Personenbild-Inhalte", state.config) { + InfoRow("Datenquelle", "/api/vereinsmeisterschaften") + InfoRow("Hinweis", "Die Ergebnisliste ist nativ im öffentlichen Bereich portiert.") + } +} + +@Composable +fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Sportbetrieb", "Training, Trainer und Mannschaftsinhalte", state.config) { config -> + InfoRow("Trainingszeiten", "${config.training.zeiten.size} Einträge") + InfoRow("Trainer", "${config.trainer.size} Personen") + InfoRow("Spielsysteme", "/data/spielsysteme.csv") + } +} + +@Composable +fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users) +} + +@Composable +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 && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") } + items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index]) } + } +} + +@Composable +fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") { + if (state.loading) item { CircularProgressIndicator(color = Primary600) } + item { SectionTitle("Newsletter") } + if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") } + items(state.newsletters.size) { index -> NewsletterCard(state.newsletters[index]) } + item { SectionTitle("Gruppen") } + if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") } + items(state.newsletterGroups.size) { index -> NewsletterGroupCard(state.newsletterGroups[index]) } + } +} + +@Composable +fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Einstellungen", "Konfiguration und Systemstatus", state.config) { config -> + InfoRow("Vorstand", listOf(config.vorstand.vorsitzender, config.vorstand.stellvertreter, config.vorstand.kassenwart).count { it.email.isNotBlank() }.toString()) + InfoRow("Trainer", config.trainer.size.toString()) + InfoRow("Trainingsort", config.training.ort.name.ifBlank { "Nicht gesetzt" }) + } +} + +@Composable +fun CmsBenutzerScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users) +} + +@Composable +fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsPage(navController, showBackNavigation, "Passwort-Reset-Diagnose", "Fehlgeschlagene Reset-Versuche") { + if (state.loading) item { CircularProgressIndicator(color = Primary600) } + if (!state.loading && state.passwordResetAttempts.isEmpty()) item { EmptyCard("Keine Diagnoseeinträge gefunden.") } + items(state.passwordResetAttempts.size) { index -> PasswordAttemptCard(state.passwordResetAttempts[index]) } + } +} + +@Composable +private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) { + val cards = listOf( + Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), + Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), + Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), + Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), + Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), + Triple("Newsletter", "${state.newsletters.size} Newsletter", Destinations.CmsNewsletter.route), + Triple("Einstellungen", "Systemkonfiguration", Destinations.CmsEinstellungen.route), + Triple("Benutzer", "Rollen und Zugänge", Destinations.CmsBenutzer.route), + Triple("Vereinsmeisterschaften", "Ergebnisdaten", Destinations.CmsVereinsmeisterschaften.route), + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + cards.forEach { (title, subtitle, route) -> + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp, modifier = Modifier.fillMaxWidth().clickable { navController.navigate(route) }) { + Column(Modifier.fillMaxWidth().padding(18.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 4.dp)) + } + } + } + } +} + +@Composable +private fun CmsPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + subtitle: String, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + if (showBackNavigation) { + TextButton(onClick = { navController.popBackStack() }) { + Text("< CMS", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + content() + } +} + +@Composable +private fun CmsConfigPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + subtitle: String, + config: ConfigResponse?, + content: @Composable (ConfigResponse) -> Unit, +) { + CmsPage(navController, showBackNavigation, title, subtitle) { + if (config == null) { + item { CircularProgressIndicator(color = Primary600) } + } else { + item { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + content(config) + } + } + } + } + } +} + +@Composable +private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List) { + CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") { + if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") } + items(users.size) { index -> UserCard(users[index]) } + } +} + +@Composable +private fun UserCard(user: CmsUserDto) { + DataCard(user.name.ifBlank { user.email.orEmpty() }) { + InfoRow("E-Mail", user.email ?: "-") + InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" }) + InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv") + InfoRow("Letzter Login", user.lastLogin ?: "-") + } +} + +@Composable +private fun ContactRequestCard(request: ContactRequestDto) { + DataCard(request.name.ifBlank { request.email }) { + InfoRow("E-Mail", request.email) + InfoRow("Status", request.status.ifBlank { "offen" }) + InfoRow("Nachricht", request.message) + } +} + +@Composable +private fun NewsletterCard(newsletter: NewsletterDto) { + DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) { + InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") + InfoRow("Erstellt", newsletter.createdAt ?: "-") + InfoRow("Versendet", newsletter.sentAt ?: "-") + } +} + +@Composable +private fun NewsletterGroupCard(group: NewsletterGroupDto) { + DataCard(group.name.ifBlank { group.id }) { + InfoRow("Beschreibung", group.description.ifBlank { "-" }) + InfoRow("Abonnenten", group.subscribers.size.toString()) + } +} + +@Composable +private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) { + DataCard(attempt.emailMasked ?: attempt.requestId) { + InfoRow("Gestartet", attempt.startedAt ?: "-") + InfoRow("Status", if (attempt.failed) "Fehler" else "OK") + InfoRow("Schritte", attempt.steps.size.toString()) + } +} + +@Composable +private fun DataCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + content() + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = Accent500, modifier = Modifier.weight(0.8f)) + Text(value, color = Accent900, modifier = Modifier.weight(1.2f)) + } +} + +@Composable +private fun SectionTitle(title: String) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) +} + +@Composable +private fun EmptyCard(message: String) { + Surface(color = Accent100, shape = RoundedCornerShape(12.dp)) { + Text(message, color = Accent700, modifier = Modifier.fillMaxWidth().padding(16.dp)) + } +} + +private fun textState(value: String): String = if (value.isBlank()) "Nicht gesetzt" else "${value.length} Zeichen" diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt new file mode 100644 index 0000000..4e4a5ab --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -0,0 +1,62 @@ +package de.harheimertc.ui.screens.cms + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.CmsUserDto +import de.harheimertc.data.ConfigResponse +import de.harheimertc.data.ContactRequestDto +import de.harheimertc.data.NewsletterDto +import de.harheimertc.data.NewsletterGroupDto +import de.harheimertc.data.PasswordResetAttemptDto +import de.harheimertc.repositories.CmsRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class CmsUiState( + val loading: Boolean = true, + val error: String? = null, + val config: ConfigResponse? = null, + val users: List = emptyList(), + val contactRequests: List = emptyList(), + val newsletters: List = emptyList(), + val newsletterGroups: List = emptyList(), + val passwordResetAttempts: List = emptyList(), +) + +@HiltViewModel +class CmsViewModel @Inject constructor( + private val repository: CmsRepository, +) : ViewModel() { + private val _state = MutableStateFlow(CmsUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + val config = async { repository.config().getOrNull() } + val users = async { repository.users().getOrNull()?.users.orEmpty() } + val requests = async { repository.contactRequests().getOrNull().orEmpty() } + val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() } + val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() } + val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() } + + _state.value = CmsUiState( + loading = false, + config = config.await(), + users = users.await(), + contactRequests = requests.await(), + newsletters = newsletters.await(), + newsletterGroups = groups.await(), + passwordResetAttempts = diagnostics.await(), + ) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt index eedafb7..b0a736b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import de.harheimertc.ui.components.ValidatedTextField @Composable fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) { @@ -21,12 +21,20 @@ fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) { val message by viewModel.message.collectAsState() val sending by viewModel.sending.collectAsState() val result by viewModel.result.collectAsState() + val fieldErrors by viewModel.fieldErrors.collectAsState() Surface(modifier = Modifier.padding(16.dp)) { Column { - OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth()) + ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"]) + ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"]) + ValidatedTextField( + value = message, + onValueChange = viewModel::onMessage, + label = "Nachricht", + error = fieldErrors["message"], + singleLine = false, + minLines = 4, + ) Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) { Text(if (sending) "Sende…" else "Absenden") } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt index 4973777..4f34248 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.ContactRequest import de.harheimertc.repositories.ContactRepository +import de.harheimertc.ui.components.isValidEmail import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -27,16 +28,38 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository) private val _result = MutableStateFlow(null) val result: StateFlow = _result - fun onName(v: String) { _name.value = v } - fun onEmail(v: String) { _email.value = v } - fun onMessage(v: String) { _message.value = v } + private val _fieldErrors = MutableStateFlow>(emptyMap()) + val fieldErrors: StateFlow> = _fieldErrors + + fun onName(v: String) { + _name.value = v + clearFieldError("name") + } + + fun onEmail(v: String) { + _email.value = v + clearFieldError("email") + } + + fun onMessage(v: String) { + _message.value = v + clearFieldError("message") + } fun send() { val n = _name.value.trim() val e = _email.value.trim() val m = _message.value.trim() - if (n.isEmpty() || e.isEmpty() || m.isEmpty()) { - _result.value = "Bitte alle Felder ausfüllen" + val errors = buildMap { + if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.") + if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.") + else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.") + if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.") + else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.") + } + if (errors.isNotEmpty()) { + _fieldErrors.value = errors + _result.value = "Bitte prüfen Sie die markierten Felder." return } viewModelScope.launch { @@ -45,6 +68,7 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository) val resp = repo.sendContact(ContactRequest(n, e, m)) if (resp.isSuccessful) { _result.value = "Nachricht gesendet" + _fieldErrors.value = emptyMap() _name.value = "" _email.value = "" _message.value = "" @@ -58,4 +82,11 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository) } } } + + private fun clearFieldError(field: String) { + if (_fieldErrors.value.containsKey(field)) { + _fieldErrors.value = _fieldErrors.value - field + } + _result.value = null + } } 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 4424807..4a0e5b9 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 @@ -52,6 +52,7 @@ import de.harheimertc.data.NewsDto import de.harheimertc.data.SpielDto import de.harheimertc.data.TerminDto import de.harheimertc.ui.components.AppNavigationHeader +import de.harheimertc.ui.components.RichText import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent200 @@ -83,7 +84,7 @@ fun HomeScreen( Text(item.title, style = MaterialTheme.typography.titleLarge) } }, - text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) }, + text = { RichText(item.content) }, confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } }, ) } @@ -221,13 +222,7 @@ private fun HomeNewsSection(news: List, onOpen: (NewsDto) -> Unit) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500) Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900) - Text( - item.content, - style = MaterialTheme.typography.bodyMedium, - color = Accent700, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + RichText(item.content) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt index b0c4439..af60c63 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt @@ -19,11 +19,13 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -32,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.ui.theme.Accent500 +import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Primary100 import de.harheimertc.ui.theme.Primary600 @@ -45,6 +48,18 @@ fun LoginScreen( viewModel: LoginViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(state.loggedIn, state.restoring) { + if (state.loggedIn && !state.restoring) { + navController.navigate(Destinations.MemberArea.route) { + launchSingleTop = true + popUpTo(Destinations.Login.route) { + inclusive = true + } + } + } + } LazyColumn( modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), @@ -80,26 +95,33 @@ fun LoginScreen( CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp)) Text("Sitzung wird geprüft...", color = Accent500) } else if (!state.loggedIn) { - OutlinedTextField( + ValidatedTextField( value = state.email, onValueChange = viewModel::setEmail, - label = { Text("E-Mail-Adresse") }, + label = "E-Mail-Adresse", + error = state.fieldErrors["email"], keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ValidatedTextField( value = state.password, onValueChange = viewModel::setPassword, - label = { Text("Passwort") }, + label = "Passwort", + error = state.fieldErrors["password"], visualTransformation = PasswordVisualTransformation(), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp)) Text(if (state.loading) "Anmeldung läuft..." else "Anmelden") } + OutlinedButton( + onClick = { viewModel.passkeyLogin(context) }, + enabled = !state.loading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Mit Passkey anmelden") + } TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) { Text("Passwort vergessen?") } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt index a69d2c6..e5cefbf 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt @@ -1,9 +1,12 @@ package de.harheimertc.ui.screens.login +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.repositories.LoginRepository +import de.harheimertc.repositories.PasskeyRepository +import de.harheimertc.ui.components.isValidEmail import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -12,6 +15,7 @@ import javax.inject.Inject data class LoginUiState( val email: String = "", val password: String = "", + val fieldErrors: Map = emptyMap(), val loading: Boolean = false, val restoring: Boolean = true, val loggedIn: Boolean = false, @@ -22,7 +26,10 @@ data class LoginUiState( ) @HiltViewModel -class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() { +class LoginViewModel @Inject constructor( + private val repository: LoginRepository, + private val passkeyRepository: PasskeyRepository, +) : ViewModel() { private val _state = MutableStateFlow(LoginUiState()) val state: StateFlow = _state @@ -42,17 +49,21 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository } fun setEmail(value: String) { - _state.value = _state.value.copy(email = value, error = null) + _state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null) } fun setPassword(value: String) { - _state.value = _state.value.copy(password = value, error = null) + _state.value = _state.value.copy(password = value, fieldErrors = _state.value.fieldErrors - "password", error = null) } fun login() { val current = _state.value - if (current.email.isBlank() || current.password.isBlank()) { - _state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.") + val fieldErrors = buildMap { + if (!isValidEmail(current.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.") + if (current.password.isBlank()) put("password", "Bitte geben Sie Ihr Passwort ein.") + } + if (fieldErrors.isNotEmpty()) { + _state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.") return } viewModelScope.launch { @@ -75,6 +86,32 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository } } + fun passkeyLogin(context: Context) { + val current = _state.value + viewModelScope.launch { + _state.value = current.copy(loading = true, error = null, message = null) + passkeyRepository.login(context, current.email) + .onSuccess { response -> + _state.value = current.copy( + password = "", + loading = false, + restoring = false, + loggedIn = true, + userName = response.user?.name ?: response.user?.email, + roles = response.user?.roles.orEmpty(), + message = "Passkey-Anmeldung erfolgreich.", + ) + } + .onFailure { + _state.value = current.copy( + loading = false, + restoring = false, + error = it.message ?: "Passkey-Anmeldung fehlgeschlagen.", + ) + } + } + } + fun logout() { viewModelScope.launch { repository.logout() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt index c5bcf91..0ca022d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Primary100 @@ -52,13 +53,13 @@ fun PasswordResetScreen( onBack = { navController.navigate(Destinations.Login.route) }, showBackNavigation = showBackNavigation, ) { - OutlinedTextField( - state.email, - viewModel::setEmail, - label = { Text("E-Mail-Adresse") }, + ValidatedTextField( + value = state.email, + onValueChange = viewModel::setEmail, + label = "E-Mail-Adresse", + error = state.fieldErrors["email"], keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) MessageLines(state.error, state.message) Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { @@ -85,40 +86,39 @@ fun RegisterScreen( onBack = { navController.navigate(Destinations.Login.route) }, showBackNavigation = showBackNavigation, ) { - OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField( - form.email, - { viewModel.update(form.copy(email = it)) }, - label = { Text("E-Mail-Adresse *") }, + ValidatedTextField(form.name, { viewModel.update(form.copy(name = it)) }, "Name *", error = state.fieldErrors["name"]) + ValidatedTextField( + value = form.email, + onValueChange = { viewModel.update(form.copy(email = it)) }, + label = "E-Mail-Adresse *", + error = state.fieldErrors["email"], keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( - form.phone, - { viewModel.update(form.copy(phone = it)) }, - label = { Text("Telefon") }, + ValidatedTextField( + value = form.phone, + onValueChange = { viewModel.update(form.copy(phone = it)) }, + label = "Telefon", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( - form.birthDate, - { viewModel.update(form.copy(birthDate = it)) }, - label = { Text("Geburtsdatum * (JJJJ-MM-TT)") }, - modifier = Modifier.fillMaxWidth(), + ValidatedTextField( + value = form.birthDate, + onValueChange = { viewModel.update(form.copy(birthDate = it)) }, + label = "Geburtsdatum * (JJJJ-MM-TT)", + error = state.fieldErrors["birthDate"], ) - OutlinedTextField( - form.password, - { viewModel.update(form.copy(password = it)) }, - label = { Text("Passwort *") }, + ValidatedTextField( + value = form.password, + onValueChange = { viewModel.update(form.copy(password = it)) }, + label = "Passwort *", + error = state.fieldErrors["password"], visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( - form.passwordRepeat, - { viewModel.update(form.copy(passwordRepeat = it)) }, - label = { Text("Passwort wiederholen *") }, + ValidatedTextField( + value = form.passwordRepeat, + onValueChange = { viewModel.update(form.copy(passwordRepeat = it)) }, + label = "Passwort wiederholen *", + error = state.fieldErrors["passwordRepeat"], visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), ) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) }) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt index 895c4eb..6097153 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt @@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.RegistrationRequest import de.harheimertc.data.RegistrationVisibility import de.harheimertc.repositories.LoginRepository +import de.harheimertc.ui.components.isValidEmail +import de.harheimertc.ui.components.isValidIsoDate import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -13,6 +15,7 @@ import javax.inject.Inject data class PasswordResetUiState( val email: String = "", + val fieldErrors: Map = emptyMap(), val loading: Boolean = false, val error: String? = null, val message: String? = null, @@ -24,13 +27,16 @@ class PasswordResetViewModel @Inject constructor(private val repository: LoginRe val state: StateFlow = _state fun setEmail(value: String) { - _state.value = _state.value.copy(email = value, error = null) + _state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null) } fun submit() { val email = _state.value.email.trim() - if (!email.contains("@")) { - _state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.") + if (!isValidEmail(email)) { + _state.value = _state.value.copy( + fieldErrors = mapOf("email" to "Bitte eine gültige E-Mail-Adresse eingeben."), + error = "Bitte prüfen Sie die markierten Felder.", + ) return } viewModelScope.launch { @@ -58,6 +64,7 @@ data class RegisterFormState( data class RegisterUiState( val form: RegisterFormState = RegisterFormState(), + val fieldErrors: Map = emptyMap(), val loading: Boolean = false, val error: String? = null, val message: String? = null, @@ -69,21 +76,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit val state: StateFlow = _state fun update(form: RegisterFormState) { - _state.value = _state.value.copy(form = form, error = null) + _state.value = _state.value.copy(form = form, fieldErrors = validateFields(form, onlyTouched = true), error = null) } fun submit() { val form = _state.value.form - val error = when { - form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() -> - "Bitte alle Pflichtfelder ausfüllen." - !form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben." - form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein." - form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein." - else -> null - } - if (error != null) { - _state.value = _state.value.copy(error = error) + val fieldErrors = validateFields(form) + if (fieldErrors.isNotEmpty()) { + _state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.") return } viewModelScope.launch { @@ -104,4 +104,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit } } } + + private fun validateFields(form: RegisterFormState, onlyTouched: Boolean = false): Map = buildMap { + fun shouldValidate(value: String) = !onlyTouched || value.isNotBlank() + if (!onlyTouched && form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.") + if (shouldValidate(form.email) && !isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.") + if (shouldValidate(form.birthDate) && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.") + if (shouldValidate(form.password) && form.password.length < 8) put("password", "Das Passwort muss mindestens 8 Zeichen lang sein.") + if (form.passwordRepeat.isNotBlank() && form.password != form.passwordRepeat) put("passwordRepeat", "Die Passwörter stimmen nicht überein.") + if (!onlyTouched && form.passwordRepeat.isBlank()) put("passwordRepeat", "Bitte wiederholen Sie das Passwort.") + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt new file mode 100644 index 0000000..378c410 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -0,0 +1,203 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.ui.Modifier +import androidx.compose.ui.graphics.Color +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.data.MemberDto +import de.harheimertc.data.NewsDto +import de.harheimertc.ui.components.RichText +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 +import de.harheimertc.ui.theme.Primary900 + +@Composable +fun MembersScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: MembersViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val query = state.query.trim() + val members = state.members + .filter { member -> + query.isBlank() || + member.name.contains(query, ignoreCase = true) || + member.email.orEmpty().contains(query, ignoreCase = true) + } + .sortedWith(compareBy { it.lastName.ifBlank { it.name } }.thenBy { it.firstName }) + + MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") { + item { + OutlinedTextField( + value = state.query, + onValueChange = viewModel::updateQuery, + label = { Text("Suchen") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + when { + state.loading -> item { CircularProgressIndicator(color = Primary600) } + state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } + members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } + else -> items(members.size) { index -> MemberCard(members[index]) } + } + } +} + +@Composable +fun MemberNewsScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: MemberNewsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") { + when { + state.loading -> item { CircularProgressIndicator(color = Primary600) } + 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]) } + } + } +} + +@Composable +fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) { + val groups = listOf( + "Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"), + "Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"), + "CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"), + ) + MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") { + item { + Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) { + Text( + "Android nutzt Authorization: Bearer . Abgelaufene Tokens werden über /api/auth/refresh automatisch erneuert.", + color = Primary900, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } + } + items(groups.size) { index -> + val (title, endpoints) = groups[index] + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + endpoints.forEach { endpoint -> + Surface(color = Accent100, shape = RoundedCornerShape(8.dp)) { + Text(endpoint, color = Accent900, modifier = Modifier.fillMaxWidth().padding(10.dp)) + } + } + } + } + } + } +} + +@Composable +private fun MemberAreaPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + subtitle: String, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + if (showBackNavigation) { + TextButton(onClick = { navController.popBackStack() }) { + Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + content() + } +} + +@Composable +private fun MemberCard(member: MemberDto) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { + Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900) + if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600) + if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700) + if (!member.birthday.isNullOrBlank()) Text("Geburtstag: ${member.birthday}", color = Accent500) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Badge(if (member.hasLogin) "Login" else member.source.ifBlank { "Mitglied" }) + if (member.isMannschaftsspieler) Badge("Mannschaft") + if (member.hasHallKey) Badge("Hallenschlüssel") + } + if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) { + Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500) + } + } + } +} + +@Composable +private fun NewsCard(item: NewsDto) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(listOfNotNull(item.author, item.created).joinToString(" | "), color = Accent500) + if (item.isPublic || item.isHidden) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (item.isPublic) Badge("Öffentlich") + if (item.isHidden) Badge("Ausgeblendet") + } + } + RichText(item.content) + } + } +} + +@Composable +private fun Badge(label: String) { + Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) { + Text(label, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp)) + } +} + +@Composable +private fun ErrorCard(message: String, onRetry: () -> Unit) { + Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(message, color = Color(0xFF991B1B)) + Button(onClick = onRetry) { Text("Erneut laden") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt new file mode 100644 index 0000000..1a24309 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt @@ -0,0 +1,71 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.MemberDto +import de.harheimertc.data.NewsDto +import de.harheimertc.repositories.MemberAreaRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MembersUiState( + val members: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, + val query: String = "", +) + +@HiltViewModel +class MembersViewModel @Inject constructor( + private val repository: MemberAreaRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MembersUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun updateQuery(query: String) { + _state.value = _state.value.copy(query = query) + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + repository.members() + .onSuccess { response -> _state.value = _state.value.copy(members = response.members, loading = false) } + .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") } + } + } +} + +data class MemberNewsUiState( + val news: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, +) + +@HiltViewModel +class MemberNewsViewModel @Inject constructor( + private val repository: MemberAreaRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MemberNewsUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + repository.news() + .onSuccess { response -> _state.value = _state.value.copy(news = response.news, loading = false) } + .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "News konnten nicht geladen werden.") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt new file mode 100644 index 0000000..97063f0 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt @@ -0,0 +1,190 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.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 +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.data.BirthdayDto +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 + +@Composable +fun MemberAreaScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: MemberAreaViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + LazyColumn( + modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item { + if (showBackNavigation) { + TextButton(onClick = { navController.popBackStack() }) { + Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text("Mitgliederbereich", style = MaterialTheme.typography.displayLarge, color = Accent900) + Text("Alles Wichtige für Vereinsmitglieder.", color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + + item { + MemberAreaCardGrid(navController) + } + + item { + BirthdayCard( + birthdays = state.birthdays, + loading = state.loadingBirthdays, + error = state.birthdayError, + onRetry = viewModel::loadBirthdays, + ) + } + } +} + +@Composable +private fun MemberAreaCardGrid(navController: NavController) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + MemberAreaCard( + title = "Mein Profil", + description = "Persönliche Daten und Passwort verwalten", + marker = "P", + onClick = { navController.navigate(Destinations.Profile.route) }, + ) + MemberAreaCard( + title = "Mitglieder", + description = "Kontaktdaten der Vereinsmitglieder", + marker = "M", + onClick = { navController.navigate(Destinations.Members.route) }, + ) + MemberAreaCard( + title = "News", + description = "Neuigkeiten und Ankündigungen", + marker = "N", + onClick = { navController.navigate(Destinations.MemberNews.route) }, + ) + } +} + +@Composable +private fun MemberAreaCard(title: String, description: String, marker: String, onClick: () -> Unit) { + Surface( + color = Color.White, + shape = RoundedCornerShape(14.dp), + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface(color = Primary100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(marker, color = Primary600, fontWeight = FontWeight.Bold) + } + } + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(description, color = Accent700, modifier = Modifier.padding(top = 4.dp)) + } + } + } +} + +@Composable +private fun BirthdayCard( + birthdays: List, + loading: Boolean, + error: String?, + onRetry: () -> Unit, +) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(color = Color(0xFFFCE7F3), shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("G", color = Color(0xFFDB2777), fontWeight = FontWeight.Bold) + } + } + Text("Geburtstage (nächste 4 Wochen)", style = MaterialTheme.typography.titleLarge, color = Accent900) + } + + when { + loading -> { + CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp)) + Text("Lade...", color = Accent500) + } + error != null -> { + Text(error, color = MaterialTheme.colorScheme.error) + TextButton(onClick = onRetry) { Text("Erneut laden") } + } + birthdays.isEmpty() -> Text("Keine Geburtstage in den nächsten 4 Wochen.", color = Accent700) + else -> birthdays.forEach { birthday -> BirthdayRow(birthday) } + } + } + } +} + +@Composable +private fun BirthdayRow(birthday: BirthdayDto) { + Surface(color = Accent100, shape = RoundedCornerShape(9.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(birthday.name, color = Accent900, fontWeight = FontWeight.SemiBold) + Text(birthday.dayMonth, color = Accent500, style = MaterialTheme.typography.labelSmall) + } + Text(relativeBirthdayLabel(birthday.inDays), color = Accent500) + } + } +} + +private fun relativeBirthdayLabel(inDays: Int): String = when (inDays) { + 0 -> "Heute" + 1 -> "Morgen" + else -> "in $inDays Tagen" +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt new file mode 100644 index 0000000..26d4e8e --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt @@ -0,0 +1,48 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.BirthdayDto +import de.harheimertc.repositories.MemberAreaRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MemberAreaUiState( + val birthdays: List = emptyList(), + val loadingBirthdays: Boolean = true, + val birthdayError: String? = null, +) + +@HiltViewModel +class MemberAreaViewModel @Inject constructor( + private val repository: MemberAreaRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MemberAreaUiState()) + val state: StateFlow = _state + + init { + loadBirthdays() + } + + fun loadBirthdays() { + viewModelScope.launch { + _state.value = _state.value.copy(loadingBirthdays = true, birthdayError = null) + repository.birthdays() + .onSuccess { response -> + _state.value = _state.value.copy( + birthdays = response.birthdays, + loadingBirthdays = false, + ) + } + .onFailure { + _state.value = _state.value.copy( + loadingBirthdays = false, + birthdayError = it.message ?: "Geburtstage konnten nicht geladen werden.", + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt index 081d9d3..ee3b0b4 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -39,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent900 @@ -85,33 +85,33 @@ fun MembershipScreen( Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) { Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900) FormHeading("Persönliche Daten") - TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) } - TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) } - TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) } + TextInput("Vorname *", form.vorname, error = state.fieldErrors["vorname"]) { viewModel.update(form.copy(vorname = it)) } + TextInput("Nachname *", form.nachname, error = state.fieldErrors["nachname"]) { viewModel.update(form.copy(nachname = it)) } + TextInput("Straße und Hausnummer *", form.strasse, error = state.fieldErrors["strasse"]) { viewModel.update(form.copy(strasse = it)) } Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) { - TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) } - TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) } + TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number, state.fieldErrors["plz"]) { viewModel.update(form.copy(plz = it)) } + TextInput("Wohnort *", form.ort, Modifier.weight(0.62f), error = state.fieldErrors["ort"]) { viewModel.update(form.copy(ort = it)) } } - TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) } - TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) } + TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum, error = state.fieldErrors["geburtsdatum"]) { viewModel.update(form.copy(geburtsdatum = it)) } + TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email, error = state.fieldErrors["email"]) { viewModel.update(form.copy(email = it)) } TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) } FormHeading("Mitgliedschaftsart") ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) } ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) } FeeInfo() - AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) { + AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift, state.fieldErrors["lastschrift"]) { viewModel.update(form.copy(lastschrift = it)) } FormHeading("Bankdaten für SEPA-Lastschrift") - TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) } - TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) } + TextInput("Kontoinhaber *", form.kontoinhaber, error = state.fieldErrors["kontoinhaber"]) { viewModel.update(form.copy(kontoinhaber = it)) } + TextInput("IBAN *", form.iban, error = state.fieldErrors["iban"]) { viewModel.update(form.copy(iban = it)) } TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) } TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) } FormHeading("Datenschutz und Vereinssatzung") - AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) { + AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz, state.fieldErrors["datenschutz"]) { viewModel.update(form.copy(datenschutz = it)) } - AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) { + AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung, state.fieldErrors["satzung"]) { viewModel.update(form.copy(satzung = it)) } state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } @@ -164,12 +164,14 @@ private fun TextInput( value: String, modifier: Modifier = Modifier.fillMaxWidth(), keyboard: KeyboardType = KeyboardType.Text, + error: String? = null, onChange: (String) -> Unit, ) { - OutlinedTextField( + ValidatedTextField( value = value, onValueChange = onChange, - label = { Text(label) }, + label = label, + error = error, keyboardOptions = KeyboardOptions(keyboardType = keyboard), modifier = modifier, singleLine = true, @@ -185,10 +187,13 @@ private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) { } @Composable -private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) { - Row(verticalAlignment = Alignment.Top) { - Checkbox(checked = selected, onCheckedChange = onChange) - Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp)) +private fun AgreementRow(label: String, selected: Boolean, error: String? = null, onChange: (Boolean) -> Unit) { + Column { + Row(verticalAlignment = Alignment.Top) { + Checkbox(checked = selected, onCheckedChange = onChange) + Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp)) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp)) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt index dee042a..c7700c7 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.MembershipRequest import de.harheimertc.repositories.MembershipRepository +import de.harheimertc.ui.components.isValidEmail +import de.harheimertc.ui.components.isValidIsoDate import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -31,6 +33,7 @@ data class MembershipFormState( data class MembershipUiState( val form: MembershipFormState = MembershipFormState(), + val fieldErrors: Map = emptyMap(), val sending: Boolean = false, val message: String? = null, val error: String? = null, @@ -43,13 +46,14 @@ class MembershipViewModel @Inject constructor(private val repository: Membership val state: StateFlow = _state fun update(form: MembershipFormState) { - _state.value = _state.value.copy(form = form, error = null) + _state.value = _state.value.copy(form = form, fieldErrors = _state.value.fieldErrors - changedKeys(_state.value.form, form), error = null) } fun submit() { val form = _state.value.form - validate(form)?.let { - _state.value = _state.value.copy(error = it) + val fieldErrors = validateFields(form) + if (fieldErrors.isNotEmpty()) { + _state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.") return } viewModelScope.launch { @@ -74,7 +78,7 @@ class MembershipViewModel @Inject constructor(private val repository: Membership ) repository.submit(request) .onSuccess { document -> - _state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri) + _state.value = _state.value.copy(sending = false, fieldErrors = emptyMap(), message = document.message, pdfUri = document.uri) } .onFailure { _state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.") @@ -82,12 +86,33 @@ class MembershipViewModel @Inject constructor(private val repository: Membership } } - private fun validate(form: MembershipFormState): String? = when { - listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban) - .any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen." - !form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben." - !form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben." - !form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen." - else -> null + private fun validateFields(form: MembershipFormState): Map = buildMap { + if (form.vorname.isBlank()) put("vorname", "Bitte geben Sie den Vornamen ein.") + if (form.nachname.isBlank()) put("nachname", "Bitte geben Sie den Nachnamen ein.") + if (form.strasse.isBlank()) put("strasse", "Bitte geben Sie Straße und Hausnummer ein.") + if (!form.plz.matches(Regex("\\d{5}"))) put("plz", "Bitte eine gültige fünfstellige PLZ eingeben.") + if (form.ort.isBlank()) put("ort", "Bitte geben Sie den Wohnort ein.") + if (!isValidIsoDate(form.geburtsdatum)) put("geburtsdatum", "Bitte verwenden Sie das Format JJJJ-MM-TT.") + if (!isValidEmail(form.email)) put("email", "Bitte eine gültige E-Mail-Adresse eingeben.") + if (form.kontoinhaber.isBlank()) put("kontoinhaber", "Bitte geben Sie den Kontoinhaber ein.") + if (form.iban.filterNot(Char::isWhitespace).length < 15) put("iban", "Bitte geben Sie eine gültige IBAN ein.") + if (!form.lastschrift) put("lastschrift", "Das SEPA-Lastschriftmandat ist erforderlich.") + if (!form.datenschutz) put("datenschutz", "Die Datenschutzeinwilligung ist erforderlich.") + if (!form.satzung) put("satzung", "Die Anerkennung der Satzung ist erforderlich.") + } + + private fun changedKeys(previous: MembershipFormState, next: MembershipFormState): Set = buildSet { + if (previous.vorname != next.vorname) add("vorname") + if (previous.nachname != next.nachname) add("nachname") + if (previous.strasse != next.strasse) add("strasse") + if (previous.plz != next.plz) add("plz") + if (previous.ort != next.ort) add("ort") + if (previous.geburtsdatum != next.geburtsdatum) add("geburtsdatum") + if (previous.email != next.email) add("email") + if (previous.kontoinhaber != next.kontoinhaber) add("kontoinhaber") + if (previous.iban != next.iban) add("iban") + if (previous.lastschrift != next.lastschrift) add("lastschrift") + if (previous.datenschutz != next.datenschutz) add("datenschutz") + if (previous.satzung != next.satzung) add("satzung") } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt new file mode 100644 index 0000000..d9f9ae6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt @@ -0,0 +1,247 @@ +package de.harheimertc.ui.screens.newsletter + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.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.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 +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +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.NewsletterGroupDto +import de.harheimertc.ui.components.ValidatedTextField +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 +import de.harheimertc.ui.theme.Primary900 + +@Composable +fun NewsletterSubscribeScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: NewsletterViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + NewsletterFormScreen( + navController = navController, + showBackNavigation = showBackNavigation, + title = "Newsletter abonnieren", + buttonLabel = "Newsletter abonnieren", + state = state, + onUpdate = viewModel::update, + onSubmit = viewModel::subscribe, + ) +} + +@Composable +fun NewsletterUnsubscribeScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: NewsletterViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + NewsletterFormScreen( + navController = navController, + showBackNavigation = showBackNavigation, + title = "Newsletter abmelden", + buttonLabel = "Newsletter abmelden", + state = state, + onUpdate = viewModel::update, + onSubmit = viewModel::unsubscribe, + showName = false, + ) +} + +@Composable +fun NewsletterConfirmScreen( + navController: NavController, + showBackNavigation: Boolean, + token: String?, + viewModel: NewsletterConfirmViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + LaunchedEffect(token) { + viewModel.confirm(token.orEmpty()) + } + NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") { + when { + state.loading -> { + CircularProgressIndicator(color = Primary600) + Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700) + } + state.error != null -> { + Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(state.error.orEmpty(), color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center) + Button(onClick = { navController.navigate(Destinations.NewsletterSubscribe.route) }) { Text("Zur Anmeldung") } + } + else -> { + Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(state.message ?: "Vielen Dank. Ihre Newsletter-Anmeldung wurde bestätigt.", color = Accent700, textAlign = TextAlign.Center) + Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") } + } + } + } +} + +@Composable +fun NewsletterConfirmedScreen(navController: NavController, showBackNavigation: Boolean) { + NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigt") { + Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900) + Text("Vielen Dank. Sie erhalten ab sofort unseren Newsletter.", color = Accent700, textAlign = TextAlign.Center) + Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") } + } +} + +@Composable +fun NewsletterUnsubscribedScreen(navController: NavController, showBackNavigation: Boolean) { + NewsletterStatusPage(navController, showBackNavigation, "Newsletter abgemeldet") { + Text("Erfolgreich abgemeldet", style = MaterialTheme.typography.titleLarge, color = Accent900) + Text("Sie erhalten keine weiteren Newsletter dieser Auswahl.", color = Accent700, textAlign = TextAlign.Center) + Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") } + } +} + +@Composable +private fun NewsletterFormScreen( + navController: NavController, + showBackNavigation: Boolean, + title: String, + buttonLabel: String, + state: NewsletterUiState, + onUpdate: (NewsletterFormState) -> Unit, + onSubmit: () -> Unit, + showName: Boolean = true, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + if (showBackNavigation) { + TextButton(onClick = { navController.popBackStack() }) { + Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + 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) } + } else { + item { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("Newsletter auswählen", style = MaterialTheme.typography.titleLarge, color = Accent900) + state.groups.forEach { group -> + NewsletterGroupOption(group, selected = group.id == state.form.selectedGroupId) { + onUpdate(state.form.copy(selectedGroupId = group.id)) + } + } + state.fieldErrors["selectedGroupId"]?.let { Text(it, color = MaterialTheme.colorScheme.error) } + ValidatedTextField( + value = state.form.email, + onValueChange = { onUpdate(state.form.copy(email = it)) }, + label = "E-Mail-Adresse", + error = state.fieldErrors["email"], + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + ) + if (showName) { + ValidatedTextField( + value = state.form.name, + onValueChange = { onUpdate(state.form.copy(name = it)) }, + label = "Name (optional)", + singleLine = true, + ) + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + state.message?.let { Text(it, color = Color(0xFF166534)) } + Button(onClick = onSubmit, enabled = !state.submitting && state.groups.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { + Text(if (state.submitting) "Wird verarbeitet..." else buttonLabel) + } + } + } + } + } + } +} + +@Composable +private fun NewsletterGroupOption(group: NewsletterGroupDto, selected: Boolean, onClick: () -> Unit) { + Surface( + color = if (selected) Primary100 else Accent100, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(if (selected) "✓" else "○", color = Primary600) + Text(group.name, color = Accent900, fontWeight = FontWeight.SemiBold) + } + if (group.description.isNotBlank()) Text(group.description, color = Accent700) + } + } +} + +@Composable +private fun NewsletterStatusPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item { + if (showBackNavigation) { + TextButton(onClick = { navController.popBackStack() }) { + Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + } + item { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + content() + } + } + } + item { + Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) { + Text("Harheimer TC Newsletter", color = Primary900, modifier = Modifier.fillMaxWidth().padding(16.dp), textAlign = TextAlign.Center) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt new file mode 100644 index 0000000..a153c14 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt @@ -0,0 +1,123 @@ +package de.harheimertc.ui.screens.newsletter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.NewsletterGroupDto +import de.harheimertc.repositories.NewsletterRepository +import de.harheimertc.ui.components.isValidEmail +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class NewsletterFormState( + val selectedGroupId: String = "", + val email: String = "", + val name: String = "", +) + +data class NewsletterUiState( + val groups: List = emptyList(), + val form: NewsletterFormState = NewsletterFormState(), + val fieldErrors: Map = emptyMap(), + val loading: Boolean = true, + val submitting: Boolean = false, + val error: String? = null, + val message: String? = null, +) + +@HiltViewModel +class NewsletterViewModel @Inject constructor( + private val repository: NewsletterRepository, +) : ViewModel() { + private val _state = MutableStateFlow(NewsletterUiState()) + val state: StateFlow = _state + + init { + loadGroups() + } + + fun loadGroups() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + repository.groups() + .onSuccess { response -> _state.value = _state.value.copy(groups = response.groups, loading = false) } + .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Newsletter konnten nicht geladen werden.") } + } + } + + fun update(form: NewsletterFormState) { + _state.value = _state.value.copy( + form = form, + fieldErrors = _state.value.fieldErrors.filterKeys { key -> + when (key) { + "selectedGroupId" -> form.selectedGroupId.isBlank() + "email" -> form.email.isBlank() || !isValidEmail(form.email) + else -> true + } + }, + error = null, + message = null, + ) + } + + fun subscribe() = submit { groupId, email, name -> repository.subscribe(groupId, email, name) } + + fun unsubscribe() = submit { groupId, email, _ -> repository.unsubscribe(groupId, email) } + + private fun submit(action: suspend (String, String, String) -> Result) { + val current = _state.value + val form = current.form + val fieldErrors = buildMap { + if (form.selectedGroupId.isBlank()) put("selectedGroupId", "Bitte Newsletter auswählen.") + if (!isValidEmail(form.email)) put("email", "Bitte gültige E-Mail-Adresse eingeben.") + } + if (fieldErrors.isNotEmpty()) { + _state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.") + return + } + viewModelScope.launch { + _state.value = current.copy(submitting = true, error = null, message = null) + action(form.selectedGroupId, form.email, form.name) + .onSuccess { response -> + _state.value = current.copy( + form = NewsletterFormState(), + fieldErrors = emptyMap(), + submitting = false, + loading = false, + groups = current.groups, + message = response.message ?: "Vorgang erfolgreich.", + ) + } + .onFailure { _state.value = current.copy(submitting = false, error = it.message ?: "Vorgang fehlgeschlagen.") } + } + } +} + +data class NewsletterConfirmUiState( + val loading: Boolean = false, + val message: String? = null, + val error: String? = null, +) + +@HiltViewModel +class NewsletterConfirmViewModel @Inject constructor( + private val repository: NewsletterRepository, +) : ViewModel() { + private val _state = MutableStateFlow(NewsletterConfirmUiState()) + val state: StateFlow = _state + + fun confirm(token: String) { + if (token.isBlank()) { + _state.value = NewsletterConfirmUiState(error = "Bestätigungstoken fehlt.") + return + } + viewModelScope.launch { + _state.value = NewsletterConfirmUiState(loading = true) + repository.confirm(token) + .onSuccess { _state.value = NewsletterConfirmUiState(message = it.message ?: "Newsletter-Anmeldung bestätigt.") } + .onFailure { _state.value = NewsletterConfirmUiState(error = it.message ?: "Bestätigung fehlgeschlagen.") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt new file mode 100644 index 0000000..e4ef8fe --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt @@ -0,0 +1,222 @@ +package de.harheimertc.ui.screens.profile + +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.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.text.KeyboardOptions +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.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.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +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.theme.Primary600 + +@Composable +fun ProfileScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val form = state.form + val context = LocalContext.current + + 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("Mein Profil", style = MaterialTheme.typography.displayLarge, color = Accent900) + Text("Kontaktdaten, Sichtbarkeit und Passwort verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + + if (state.loading) { + item { + Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = Primary600) + } + } + } else { + item { + ProfileCard("Persönliche Daten") { + ValidatedTextField( + value = form.name, + onValueChange = { viewModel.update(form.copy(name = it)) }, + label = "Name", + error = state.fieldErrors["name"], + singleLine = true, + ) + ValidatedTextField( + value = form.email, + onValueChange = { viewModel.update(form.copy(email = it)) }, + label = "E-Mail", + error = state.fieldErrors["email"], + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + ) + ValidatedTextField( + value = form.phone, + onValueChange = { viewModel.update(form.copy(phone = it)) }, + label = "Telefon", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + singleLine = true, + ) + ValidatedTextField( + value = form.birthDate, + onValueChange = { viewModel.update(form.copy(birthDate = it)) }, + label = "Geburtsdatum (JJJJ-MM-TT)", + error = state.fieldErrors["birthDate"], + singleLine = true, + ) + } + } + + item { + ProfileCard("Sichtbarkeit im Mitgliederbereich") { + VisibilityRow("E-Mail anzeigen", form.showEmail) { viewModel.update(form.copy(showEmail = it)) } + VisibilityRow("Telefon anzeigen", form.showPhone) { viewModel.update(form.copy(showPhone = it)) } + VisibilityRow("Adresse anzeigen", form.showAddress) { viewModel.update(form.copy(showAddress = it)) } + VisibilityRow("Geburtstag anzeigen", form.showBirthday) { viewModel.update(form.copy(showBirthday = it)) } + } + } + + item { + ProfileCard("Passwort ändern") { + ValidatedTextField( + value = form.currentPassword, + onValueChange = { viewModel.update(form.copy(currentPassword = it)) }, + label = "Aktuelles Passwort", + error = state.fieldErrors["currentPassword"], + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + ) + ValidatedTextField( + value = form.newPassword, + onValueChange = { viewModel.update(form.copy(newPassword = it)) }, + label = "Neues Passwort", + error = state.fieldErrors["newPassword"], + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + ) + ValidatedTextField( + value = form.confirmPassword, + onValueChange = { viewModel.update(form.copy(confirmPassword = it)) }, + label = "Neues Passwort wiederholen", + error = state.fieldErrors["confirmPassword"], + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + ) + Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500) + } + } + + item { + ProfileCard("Passkeys") { + Text( + "Passkeys ermöglichen eine Anmeldung ohne Passwort über den Android Credential Manager.", + color = Accent500, + ) + Button( + onClick = { viewModel.addPasskey(context) }, + enabled = !state.passkeyLoading, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.passkeyLoading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp)) + Text(if (state.passkeyLoading) "Passkey wird erstellt..." else "Passkey hinzufügen") + } + if (state.passkeys.isEmpty()) { + Text("Noch kein Passkey hinterlegt.", color = Accent500) + } else { + state.passkeys.forEach { passkey -> + Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(9.dp)) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(passkey.name.ifBlank { "Passkey" }, color = Accent900, fontWeight = FontWeight.SemiBold) + passkey.createdAt?.let { Text("Erstellt: $it", color = Accent500) } + passkey.lastUsedAt?.let { Text("Zuletzt genutzt: $it", color = Accent500) } + OutlinedButton( + onClick = { viewModel.removePasskey(passkey.credentialId) }, + enabled = !state.passkeyLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Entfernen") + } + } + } + } + } + } + } + + item { + Button( + onClick = viewModel::save, + enabled = !state.saving, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.saving) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp)) + Text(if (state.saving) "Speichert..." else "Profil speichern") + } + } + } + + state.error?.let { message -> + item { Text(message, color = MaterialTheme.colorScheme.error) } + } + state.message?.let { message -> + item { Text(message, color = Color(0xFF166534)) } + } + } +} + +@Composable +private fun ProfileCard(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 VisibilityRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(label, color = Accent900) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt new file mode 100644 index 0000000..8c1f4ba --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt @@ -0,0 +1,217 @@ +package de.harheimertc.ui.screens.profile + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.harheimertc.data.PasskeyDto +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ProfileUpdateRequest +import de.harheimertc.data.ProfileVisibilityDto +import de.harheimertc.repositories.ProfileRepository +import de.harheimertc.repositories.PasskeyRepository +import de.harheimertc.ui.components.isValidEmail +import de.harheimertc.ui.components.isValidIsoDate +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ProfileFormState( + val name: String = "", + val email: String = "", + val phone: String = "", + val birthDate: String = "", + val showEmail: Boolean = true, + val showPhone: Boolean = true, + val showAddress: Boolean = false, + val showBirthday: Boolean = true, + val currentPassword: String = "", + val newPassword: String = "", + val confirmPassword: String = "", +) + +data class ProfileUiState( + val form: ProfileFormState = ProfileFormState(), + val passkeys: List = emptyList(), + val fieldErrors: Map = emptyMap(), + val loading: Boolean = true, + val saving: Boolean = false, + val passkeyLoading: Boolean = false, + val error: String? = null, + val message: String? = null, +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: ProfileRepository, + private val passkeyRepository: PasskeyRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ProfileUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null, message = null) + repository.load() + .onSuccess { response -> + val user = response.user + val visibility = user?.visibility ?: ProfileVisibilityDto() + _state.value = ProfileUiState( + loading = false, + form = ProfileFormState( + name = user?.name.orEmpty(), + email = user?.email.orEmpty(), + phone = user?.phone.orEmpty(), + birthDate = user?.geburtsdatum.orEmpty(), + showEmail = visibility.showEmail, + showPhone = visibility.showPhone, + showAddress = visibility.showAddress, + showBirthday = visibility.showBirthday, + ), + ) + loadPasskeys() + } + .onFailure { + _state.value = _state.value.copy( + loading = false, + error = it.message ?: "Profil konnte nicht geladen werden.", + ) + } + } + } + + fun loadPasskeys() { + viewModelScope.launch { + passkeyRepository.list() + .onSuccess { response -> + _state.value = _state.value.copy(passkeys = response.passkeys, passkeyLoading = false) + } + .onFailure { + _state.value = _state.value.copy( + passkeyLoading = false, + error = it.message ?: "Passkeys konnten nicht geladen werden.", + ) + } + } + } + + fun addPasskey(context: Context) { + viewModelScope.launch { + _state.value = _state.value.copy(passkeyLoading = true, error = null, message = null) + passkeyRepository.add(context) + .onSuccess { response -> + _state.value = _state.value.copy( + passkeyLoading = false, + message = response.message ?: "Passkey hinzugefügt.", + ) + loadPasskeys() + } + .onFailure { + _state.value = _state.value.copy( + passkeyLoading = false, + error = it.message ?: "Passkey konnte nicht hinzugefügt werden.", + ) + } + } + } + + fun removePasskey(credentialId: String) { + viewModelScope.launch { + _state.value = _state.value.copy(passkeyLoading = true, error = null, message = null) + passkeyRepository.remove(credentialId) + .onSuccess { response -> + _state.value = _state.value.copy( + passkeyLoading = false, + message = response.message ?: "Passkey entfernt.", + ) + loadPasskeys() + } + .onFailure { + _state.value = _state.value.copy( + passkeyLoading = false, + error = it.message ?: "Passkey konnte nicht entfernt werden.", + ) + } + } + } + + fun update(form: ProfileFormState) { + _state.value = _state.value.copy(form = form, fieldErrors = emptyMap(), error = null, message = null) + } + + fun save() { + val form = _state.value.form + val fieldErrors = buildMap { + if (form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.") + if (!isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.") + if (form.birthDate.isNotBlank() && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.") + if (form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank()) { + if (form.currentPassword.isBlank()) put("currentPassword", "Bitte geben Sie Ihr aktuelles Passwort ein.") + if (form.newPassword.isBlank()) put("newPassword", "Bitte geben Sie ein neues Passwort ein.") + else if (form.newPassword.length < 6) put("newPassword", "Das neue Passwort muss mindestens 6 Zeichen lang sein.") + if (form.newPassword != form.confirmPassword) put("confirmPassword", "Die neuen Passwörter stimmen nicht überein.") + } + } + if (fieldErrors.isNotEmpty()) { + _state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.", message = null) + return + } + + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.save( + ProfileUpdateRequest( + name = form.name.trim(), + email = form.email.trim(), + phone = form.phone.trim().takeIf(String::isNotBlank), + geburtsdatum = form.birthDate.trim().takeIf(String::isNotBlank), + visibility = ProfileVisibilityDto( + showEmail = form.showEmail, + showPhone = form.showPhone, + showAddress = form.showAddress, + showBirthday = form.showBirthday, + ), + currentPassword = form.currentPassword.takeIf(String::isNotBlank), + newPassword = form.newPassword.takeIf(String::isNotBlank), + ), + ).onSuccess { response -> + val current = _state.value + val next = response.user + val visibility = next?.visibility ?: ProfileVisibilityDto( + form.showEmail, + form.showPhone, + form.showAddress, + form.showBirthday, + ) + _state.value = ProfileUiState( + loading = false, + message = response.message ?: "Profil erfolgreich aktualisiert.", + passkeys = current.passkeys, + fieldErrors = emptyMap(), + form = form.copy( + name = next?.name ?: form.name.trim(), + email = next?.email ?: form.email.trim(), + phone = next?.phone ?: form.phone.trim(), + birthDate = next?.geburtsdatum ?: form.birthDate.trim(), + showEmail = visibility.showEmail, + showPhone = visibility.showPhone, + showAddress = visibility.showAddress, + showBirthday = visibility.showBirthday, + currentPassword = "", + newPassword = "", + confirmPassword = "", + ), + ) + }.onFailure { + _state.value = _state.value.copy( + saving = false, + error = it.message ?: "Profil konnte nicht gespeichert werden.", + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt new file mode 100644 index 0000000..59b701e --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt @@ -0,0 +1,105 @@ +package de.harheimertc.ui.screens.publicpages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.data.ConfigResponse +import de.harheimertc.ui.theme.Accent700 +import de.harheimertc.ui.theme.Accent900 +import de.harheimertc.ui.theme.Primary600 + +@Composable +fun ImpressumScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: PublicConfigViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + PublicPage(navController, showBackNavigation, "Impressum") { + when { + state.loading -> item { PublicLoading() } + state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) } + state.config != null -> { + val config = state.config + item { ImpressumContent(config!!, onOpenSatzung = { navController.navigate(de.harheimertc.ui.navigation.Destinations.Satzung.route) }, onMail = { context.openPublicUri("mailto:$it") }) } + } + } + } +} + +@Composable +private fun ImpressumContent(config: ConfigResponse, onOpenSatzung: () -> Unit, onMail: (String) -> Unit) { + val address = if (config.verein.useVorsitzenderAddress) { + listOf(config.vorstand.vorsitzender.strasse, "${config.vorstand.vorsitzender.plz} ${config.vorstand.vorsitzender.ort}".trim()) + } else { + listOf(config.verein.strasse, "${config.verein.plz} ${config.verein.ort}".trim()) + }.filter(String::isNotBlank) + + PublicCard("Angaben gemäß § 5 TMG") { + Text(config.verein.name.ifBlank { "Harheimer Tischtennis-Club 1954 e. V. (HTC)" }, color = Accent700) + address.forEach { Text(it, color = Accent700) } + } + PublicCard("Kontakt") { + config.vorstand.vorsitzender.telefon.takeIf(String::isNotBlank)?.let { Text("Telefon: $it", color = Accent700) } + config.vorstand.vorsitzender.email.takeIf(String::isNotBlank)?.let { email -> + Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) } + } + Text("Internet: www.harheimertc.de", color = Accent700) + } + PublicCard("Vertretungsberechtigter Vorstand") { + boardRows(config).forEach { Text(it, color = Accent700) } + } + PublicCard("Registereintrag") { + Text("lsb h-Vereinsnummer: 24091", color = Accent700) + Text("Registereintrag: Amtsgericht Frankfurt am Main, Registergericht", color = Accent700) + Text("Registernummer: VR 6835", color = Accent700) + } + PublicCard("Vereinssatzung") { + Text("Unsere aktuelle Vereinssatzung können Sie online einsehen.", color = Accent700) + Button(onClick = onOpenSatzung, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Satzung öffnen") } + } + PublicCard("Verantwortlich für die Website") { + val person = config.website.verantwortlicher + Text("${person.vorname} ${person.nachname}".trim().ifBlank { "-" }, color = Accent700) + person.email.takeIf(String::isNotBlank)?.let { email -> + Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) } + } + } + PublicCard("Haftungsausschluss und Datenschutz") { + SectionText("Haftung für Inhalte", "Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Eine Haftung für externe oder fehlerhafte Informationen ist erst ab Kenntnis einer konkreten Rechtsverletzung möglich.") + SectionText("Haftung für Links", "Für Inhalte externer Websites sind deren Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir entsprechende Links.") + SectionText("Urheberrecht", "Die Inhalte dieser App und Website unterliegen dem deutschen Urheberrecht.") + SectionText("Datenschutz", "Personenbezogene Daten werden vertraulich und entsprechend der Datenschutzvorschriften behandelt.") + } +} + +@Composable +private fun SectionText(title: String, text: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, color = Accent900, fontWeight = FontWeight.SemiBold) + Text(text, color = Accent700) + } +} + +private fun boardRows(config: ConfigResponse): List = listOf( + "Vorsitzender" to config.vorstand.vorsitzender, + "Stellvertreter" to config.vorstand.stellvertreter, + "Kassenwart" to config.vorstand.kassenwart, + "Schriftführer" to config.vorstand.schriftfuehrer, + "Sportwart" to config.vorstand.sportwart, + "Jugendwart" to config.vorstand.jugendwart, +).mapNotNull { (role, member) -> + val name = "${member.vorname} ${member.nachname}".trim() + name.takeIf(String::isNotBlank)?.let { "$it, $role" } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt index aa7a82c..1e8bee5 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt @@ -3,8 +3,6 @@ package de.harheimertc.ui.screens.publicpages import android.content.Context import android.content.Intent import android.net.Uri -import android.text.method.LinkMovementMethod -import android.widget.TextView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,10 +25,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.text.HtmlCompat import androidx.navigation.NavController import de.harheimertc.BuildConfig +import de.harheimertc.ui.components.RichText import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Primary600 @@ -86,20 +83,7 @@ internal fun PublicError(message: String, retry: () -> Unit) { @Composable internal fun HtmlContent(html: String) { - AndroidView( - modifier = Modifier.fillMaxWidth(), - factory = { context -> - TextView(context).apply { - textSize = 17f - setTextColor(android.graphics.Color.rgb(63, 63, 70)) - movementMethod = LinkMovementMethod.getInstance() - setLineSpacing(0f, 1.2f) - } - }, - update = { textView -> - textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) - }, - ) + RichText(html) } internal fun Context.openPublicUri(value: String) { diff --git a/components/Facilities.vue b/components/Facilities.vue deleted file mode 100644 index 9587bfd..0000000 --- a/components/Facilities.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - diff --git a/components/ModalDialog.vue b/components/ModalDialog.vue index 5998f8d..e9bc003 100644 --- a/components/ModalDialog.vue +++ b/components/ModalDialog.vue @@ -1,43 +1,14 @@ - diff --git a/server/api/auth/passkeys/list.get.js b/server/api/auth/passkeys/list.get.js index a2db2a5..a8eff2d 100644 --- a/server/api/auth/passkeys/list.get.js +++ b/server/api/auth/passkeys/list.get.js @@ -1,7 +1,7 @@ import { getUserFromToken } from '../../../utils/auth.js' export default defineEventHandler(async (event) => { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const user = token ? await getUserFromToken(token) : null if (!user) { @@ -24,4 +24,3 @@ export default defineEventHandler(async (event) => { } }) - diff --git a/server/api/auth/passkeys/login.post.js b/server/api/auth/passkeys/login.post.js index 8e1448d..fe82cac 100644 --- a/server/api/auth/passkeys/login.post.js +++ b/server/api/auth/passkeys/login.post.js @@ -1,5 +1,5 @@ import { verifyAuthenticationResponse } from '@simplewebauthn/server' -import { createSession, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js' +import { createRefreshSession, createSession, generateAndroidAccessToken, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js' import { getWebAuthnConfig } from '../../../utils/webauthn-config.js' import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js' import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js' @@ -39,6 +39,7 @@ export default defineEventHandler(async (event) => { const ip = getClientIp(event) const body = await readBody(event) + const isAndroidClient = body?.client === 'android' const response = body?.credential if (!response) { throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' }) @@ -105,10 +106,21 @@ export default defineEventHandler(async (event) => { passkey.lastUsedAt = new Date().toISOString() await writeUsers(users) - const token = generateToken(user) - await createSession(user.id, token) + let token + let refreshSession = null + if (isAndroidClient) { + refreshSession = await createRefreshSession(user.id, body?.deviceName || 'Harheimer TC Android-App') + token = generateAndroidAccessToken(user, refreshSession.session.id) + } else { + token = generateToken(user) + await createSession(user.id, token) + } - setCookie(event, 'auth_token', token, { ...getAuthCookieOptions() }) + if (isAndroidClient) { + deleteCookie(event, 'auth_token') + } else { + setCookie(event, 'auth_token', token, { ...getAuthCookieOptions() }) + } await writeAuditLog('auth.passkey.login.success', { ip, userId: user.id }) @@ -120,6 +132,9 @@ export default defineEventHandler(async (event) => { return { success: true, token, + accessToken: isAndroidClient ? token : undefined, + refreshToken: refreshSession?.refreshToken, + sessionId: refreshSession?.session.id, user: { id: user.id, email: user.email, @@ -129,4 +144,3 @@ export default defineEventHandler(async (event) => { role: roles[0] || 'mitglied' } }) - diff --git a/server/api/auth/passkeys/register.post.js b/server/api/auth/passkeys/register.post.js index c10c2e5..d2ebed7 100644 --- a/server/api/auth/passkeys/register.post.js +++ b/server/api/auth/passkeys/register.post.js @@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => { return { success: true } } - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const user = token ? await getUserFromToken(token) : null if (!user) { @@ -119,4 +119,3 @@ export default defineEventHandler(async (event) => { await writeAuditLog('auth.passkey.registered', { userId: user.id }) return { success: true, message: 'Passkey hinzugefügt.' } }) - diff --git a/server/api/auth/passkeys/registration-options.post.js b/server/api/auth/passkeys/registration-options.post.js index fc90622..ab7ad14 100644 --- a/server/api/auth/passkeys/registration-options.post.js +++ b/server/api/auth/passkeys/registration-options.post.js @@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => { ? body.preferredAuthenticatorType : undefined - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const user = token ? await getUserFromToken(token) : null if (!user) { @@ -83,4 +83,3 @@ export default defineEventHandler(async (event) => { return { success: true, options } }) - diff --git a/server/api/auth/passkeys/remove.post.js b/server/api/auth/passkeys/remove.post.js index 53ad72c..4efc4f6 100644 --- a/server/api/auth/passkeys/remove.post.js +++ b/server/api/auth/passkeys/remove.post.js @@ -2,7 +2,7 @@ import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js' import { writeAuditLog } from '../../../utils/audit-log.js' export default defineEventHandler(async (event) => { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const currentUser = token ? await getUserFromToken(token) : null if (!currentUser) { @@ -36,4 +36,3 @@ export default defineEventHandler(async (event) => { } }) - diff --git a/server/api/birthdays.get.js b/server/api/birthdays.get.js index 5c210eb..657bc2d 100644 --- a/server/api/birthdays.get.js +++ b/server/api/birthdays.get.js @@ -1,5 +1,5 @@ -import { readMembers, normalizeDate } from '../utils/members.js' -import { readUsers, migrateUserRoles, getUserFromToken, verifyToken } from '../utils/auth.js' +import { readMembers } from '../utils/members.js' +import { readUsers, getUserFromToken, verifyToken } from '../utils/auth.js' // Helper: returns array of upcoming birthdays within daysAhead (inclusive) function getUpcomingBirthdays(entries, daysAhead = 28) { @@ -41,7 +41,7 @@ function getUpcomingBirthdays(entries, daysAhead = 28) { export default defineEventHandler(async (event) => { try { // Determine viewer for visibility rules; token optional - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') let currentUser = null if (token) { const decoded = verifyToken(token) diff --git a/server/api/cms/contact-requests.get.js b/server/api/cms/contact-requests.get.js index e63947a..3c3d01d 100644 --- a/server/api/cms/contact-requests.get.js +++ b/server/api/cms/contact-requests.get.js @@ -2,7 +2,7 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js' import { readContactRequests } from '../../utils/contact-requests.js' export default defineEventHandler(async (event) => { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const currentUser = token ? await getUserFromToken(token) : null if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) { diff --git a/server/api/cms/password-reset-diagnostics.get.js b/server/api/cms/password-reset-diagnostics.get.js index f7e8872..dbaa679 100644 --- a/server/api/cms/password-reset-diagnostics.get.js +++ b/server/api/cms/password-reset-diagnostics.get.js @@ -48,7 +48,7 @@ function summarizeAttempts(entries) { } export default defineEventHandler(async (event) => { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const currentUser = token ? await getUserFromToken(token) : null if (!currentUser || !hasRole(currentUser, 'admin')) { diff --git a/server/api/cms/users/list.get.js b/server/api/cms/users/list.get.js index edbab63..a844064 100644 --- a/server/api/cms/users/list.get.js +++ b/server/api/cms/users/list.get.js @@ -1,8 +1,8 @@ -import { getUserFromToken, readUsers, hasAnyRole, hasRole, migrateUserRoles } from '../../../utils/auth.js' +import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const currentUser = await getUserFromToken(token) // Seite darf von Admin ODER Vorstand genutzt werden @@ -15,8 +15,6 @@ export default defineEventHandler(async (event) => { const users = await readUsers() - const isVorstand = hasRole(currentUser, 'vorstand') - // Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen. const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand') @@ -53,4 +51,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/config.put.js b/server/api/config.put.js index 9d4f768..ed04aa5 100644 --- a/server/api/config.put.js +++ b/server/api/config.put.js @@ -16,7 +16,7 @@ const getDataPath = (filename) => { export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/members.get.js b/server/api/members.get.js index 6162e41..a08e6c0 100644 --- a/server/api/members.get.js +++ b/server/api/members.get.js @@ -4,7 +4,7 @@ import { readUsers, migrateUserRoles } from '../utils/auth.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -203,7 +203,6 @@ export default defineEventHandler(async (event) => { mergedMembers.sort((a, b) => a.name.localeCompare(b.name)) // Die Mitgliederliste ist nur für authentifizierte Nutzer sichtbar (siehe oben). // Respektiere individuelle Sichtbarkeitspräferenzen (user.visibility) - const currentUserToken = token const isViewerAuthenticated = !!currentUser // Only 'vorstand' may override member visibility const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false @@ -218,16 +217,11 @@ export default defineEventHandler(async (event) => { const showPhone = visibility.showPhone === undefined ? true : Boolean(visibility.showPhone) const showAddress = visibility.showAddress === undefined ? false : Boolean(visibility.showAddress) - // Determine if contact info existed but was hidden to the viewer - const hadEmail = !!member.email - const hadPhone = !!member.phone - const hadAddress = !!member.address const hadBirthday = !!member.geburtsdatum const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail)) const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone)) const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress)) const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true))) - const contactHidden = (!emailVisible && hadEmail) || (!phoneVisible && hadPhone) || (!addressVisible && hadAddress) return { id: member.id, @@ -260,7 +254,7 @@ export default defineEventHandler(async (event) => { const day = `${d.getDate()}`.padStart(2, '0') const month = `${d.getMonth()+1}`.padStart(2, '0') return `${day}.${month}` - } catch (_e) { + } catch { return undefined } })() : undefined, @@ -277,4 +271,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/news.delete.js b/server/api/news.delete.js index ec335a9..76c68d0 100644 --- a/server/api/news.delete.js +++ b/server/api/news.delete.js @@ -3,7 +3,7 @@ import { deleteNews } from '../utils/news.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -53,4 +53,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/news.get.js b/server/api/news.get.js index 8785e14..f754fcd 100644 --- a/server/api/news.get.js +++ b/server/api/news.get.js @@ -3,7 +3,7 @@ import { readNews } from '../utils/news.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -35,4 +35,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/news.post.js b/server/api/news.post.js index 608774d..9c32d16 100644 --- a/server/api/news.post.js +++ b/server/api/news.post.js @@ -3,7 +3,7 @@ import { saveNews } from '../utils/news.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -60,4 +60,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/profile.get.js b/server/api/profile.get.js index 25946c4..c645677 100644 --- a/server/api/profile.get.js +++ b/server/api/profile.get.js @@ -2,7 +2,7 @@ import { verifyToken, getUserFromToken } from '../utils/auth.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' }) @@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => { email: user.email, phone: user.phone || '', geburtsdatum: user.geburtsdatum || '', - visibility: Object.assign({ showBirthday: true }, (user.visibility || {})) + visibility: Object.assign({ showBirthday: true }, (user.visibility || {})) } } } catch (error) { @@ -35,4 +35,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/profile.put.js b/server/api/profile.put.js index 168a4ae..3a8bd6e 100644 --- a/server/api/profile.put.js +++ b/server/api/profile.put.js @@ -1,9 +1,9 @@ -import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js' +import { verifyToken, getUserFromToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js' import { assertPasswordNotPwned } from '../utils/hibp.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -21,6 +21,16 @@ export default defineEventHandler(async (event) => { }) } + if (decoded.sid) { + const sessionUser = await getUserFromToken(token) + if (!sessionUser) { + throw createError({ + statusCode: 401, + message: 'Ungültige Sitzung.' + }) + } + } + const body = await readBody(event) const { name, email, phone, geburtsdatum, currentPassword, newPassword } = body @@ -31,7 +41,7 @@ export default defineEventHandler(async (event) => { }) } - const users = await readUsers() + const users = await readUsers() const userIndex = users.findIndex(u => u.id === decoded.id) if (userIndex === -1) { diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts index 7481b25..57dc6fd 100644 --- a/tests/config-profile-endpoints.spec.ts +++ b/tests/config-profile-endpoints.spec.ts @@ -141,6 +141,20 @@ describe('Config & Profil Endpoints', () => { expect(result.user.email).toBe('max@test.de') expect(result.user).not.toHaveProperty('password') }) + + it('akzeptiert Bearer-Token für Android-Clients', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue({ + id: '1', name: 'Android User', email: 'android@test.de', roles: ['mitglied'] + }) + + const result = await profileGetHandler(event) + + expect(result.success).toBe(true) + expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token') + expect(result.user.email).toBe('android@test.de') + }) }) describe('PUT /api/profile', () => { @@ -206,6 +220,32 @@ describe('Config & Profil Endpoints', () => { expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled() }) + it('aktualisiert Profil per Bearer-Token für Android-Clients', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.readUsers.mockResolvedValue([ + { id: '1', name: 'Alt', email: 'max@test.de', password: 'hash', roles: ['mitglied'] } + ]) + authUtils.writeUsers.mockResolvedValue(undefined) + authUtils.migrateUserRoles.mockImplementation(u => ({ ...u, roles: u.roles || ['mitglied'] })) + mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de', phone: '0987' }) + + const result = await profilePutHandler(event) + + expect(result.success).toBe(true) + expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token') + expect(result.user.name).toBe('Android Neu') + }) + + it('lehnt widerrufene Android-Sessions beim Profil-Update ab', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1', sid: 'session-1' }) + authUtils.getUserFromToken.mockResolvedValue(null) + mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de' }) + + await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) + it('prüft aktuelles Passwort bei Passwortänderung', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) diff --git a/tests/spielplan-public-endpoints.spec.ts b/tests/spielplan-public-endpoints.spec.ts index d620438..7cff8d9 100644 --- a/tests/spielplan-public-endpoints.spec.ts +++ b/tests/spielplan-public-endpoints.spec.ts @@ -233,6 +233,26 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => { expect(result.birthdays).toHaveLength(1) }) + it('akzeptiert Bearer-Token für Android-Clients', async () => { + const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) + const inDays = 7 + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + inDays) + const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}` + + authUtils.verifyToken.mockReturnValue({ id: 'v1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: 'v1', roles: ['vorstand'], active: true }) + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Android', lastName: 'Privat', geburtsdatum, visibility: { showBirthday: false } } + ]) + authUtils.readUsers.mockResolvedValue([]) + + const result = await birthdaysHandler(event) + + expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token') + expect(result.birthdays).toHaveLength(1) + }) + it('ignoriert Mitglieder ohne Geburtsdatum', async () => { const event = createEvent() memberUtils.readMembers.mockResolvedValue([