From 52719d5625303f1ab6d9948d292887f24f195c0c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 15 May 2026 09:06:31 +0200 Subject: [PATCH] Enhance Google OAuth functionality in Profile view. Implement linking and unlinking of Google accounts with corresponding UI updates. Add loading states and feedback messages. Update mobile app to support OAuth identity management and integrate new API endpoints for fetching and unlinking identities. Increment version code to 5 and update version name to 0.8.0-alpha4. --- .google.oauth | 4 + frontend/src/views/Profile.vue | 73 ++++++++++++++++++- mobile-app/composeApp/build.gradle.kts | 4 +- .../timeclock/data/api/SettingsDtos.kt | 14 ++++ .../timeclock/data/api/TimeClockApiClient.kt | 8 ++ .../data/settings/SettingsRepository.kt | 5 ++ .../de/tsschulz/timeclock/ui/TimeClockApp.kt | 1 + .../timeclock/ui/settings/SettingsScreens.kt | 14 +++- .../ui/settings/SettingsViewModel.kt | 13 +++- 9 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 .google.oauth diff --git a/.google.oauth b/.google.oauth new file mode 100644 index 0000000..7e624dc --- /dev/null +++ b/.google.oauth @@ -0,0 +1,4 @@ +852092562069-58f9qmu2m0anqg410iaen3e5pa3fla9e.apps.googleusercontent.com +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 + +{"web":{"client_id":"852092562069-58f9qmu2m0anqg410iaen3e5pa3fla9e.apps.googleusercontent.com","project_id":"stechuhr-496406","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["https://stechuhr3.tsschulz.de/api/auth/google/callback"]}} \ No newline at end of file diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue index 454f9a2..f9effde 100644 --- a/frontend/src/views/Profile.vue +++ b/frontend/src/views/Profile.vue @@ -89,9 +89,15 @@
- +
@@ -118,7 +124,7 @@ @@ -302,6 +355,20 @@ onMounted(async () => { cursor: not-allowed; } +.oauth-status { + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background: #f5f5f5; + color: #555; +} + +.oauth-status.linked { + border-color: #d6e9c6; + background: #dff0d8; + color: #3c763d; +} + .form-group input:focus, .form-group select:focus { outline: none; diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index 2fc1e81..d9e3d3e 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "de.tsschulz.timeclock" minSdk = 26 targetSdk = 36 - versionCode = 4 - versionName = "0.8.0-alpha3" + versionCode = 5 + versionName = "0.8.0-alpha4" buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") } diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/SettingsDtos.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/SettingsDtos.kt index 627f8c4..0fe4354 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/SettingsDtos.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/SettingsDtos.kt @@ -36,6 +36,20 @@ data class PasswordChangeRequest( val confirmPassword: String, ) +@Serializable +data class OAuthIdentitiesResponse( + val success: Boolean = false, + val identities: List = emptyList(), + val error: String? = null, +) + +@Serializable +data class OAuthIdentityDto( + val provider: String, + val identity: String? = null, + val id: String? = null, +) + @Serializable data class TimewishDto( val id: String, diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeClockApiClient.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeClockApiClient.kt index 32a22e4..55d78f6 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeClockApiClient.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeClockApiClient.kt @@ -80,6 +80,14 @@ class TimeClockApiClient( return decode(OAuthLinkUrlResponse.serializer(), raw) } + suspend fun getOAuthIdentities(): OAuthIdentitiesResponse = + decode(OAuthIdentitiesResponse.serializer(), execute(authorized("auth/identities").get().build())) + + suspend fun unlinkOAuthProvider(provider: String): MessageResponse { + val raw = execute(authorized("auth/identity/$provider").delete().build()) + return decode(MessageResponse.serializer(), raw.ifBlank { "{}" }) + } + suspend fun linkExistingOAuthAccount(pendingToken: String, email: String, password: String): LoginResponse { val raw = execute( Request.Builder() diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/settings/SettingsRepository.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/settings/SettingsRepository.kt index 4b5e9fb..471efce 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/settings/SettingsRepository.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/settings/SettingsRepository.kt @@ -2,6 +2,7 @@ package de.tsschulz.timeclock.data.settings import de.tsschulz.timeclock.data.api.InvitationDto import de.tsschulz.timeclock.data.api.InviteRequest +import de.tsschulz.timeclock.data.api.OAuthIdentityDto import de.tsschulz.timeclock.data.api.PasswordChangeRequest import de.tsschulz.timeclock.data.api.ProfileDto import de.tsschulz.timeclock.data.api.ProfileUpdateRequest @@ -28,6 +29,10 @@ class SettingsRepository( return response.url } + suspend fun getOAuthIdentities(): List = api.getOAuthIdentities().identities + + suspend fun unlinkOAuthProvider(provider: String) = api.unlinkOAuthProvider(provider) + suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword)) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt index c1e1542..e1dc090 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt @@ -256,6 +256,7 @@ private fun DemoScreen( settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType) }, onLinkGoogle = { settingsViewModel.startGoogleLink() }, + onUnlinkGoogle = { settingsViewModel.unlinkGoogle() }, ) } AppRoute.Password -> PasswordScreen( diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt index 5caf85e..78a8f06 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt @@ -50,6 +50,7 @@ fun ProfileScreen( isTablet: Boolean, onSave: (String, String?, Int, Double, Int) -> Unit, onLinkGoogle: () -> Unit, + onUnlinkGoogle: () -> Unit, ) { val profile = state.profile var fullName by rememberSaveable { mutableStateOf("") } @@ -82,7 +83,18 @@ fun ProfileScreen( TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0") TitleTypeDropdown(preferredTitleType, { preferredTitleType = it }) FieldLabel("Google-Anmeldung") - TcButton("Mit Google-Konto verknüpfen", variant = ButtonVariant.Default, onClick = onLinkGoogle) + val googleLinked = state.oauthIdentities.any { it.provider == "google" } + Text( + text = if (googleLinked) "Google-Konto ist verknüpft" else "Kein Google-Konto verknüpft", + color = if (googleLinked) TcColors.Success else TcColors.TextMuted, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + if (googleLinked) { + TcButton("Google-Verknüpfung entfernen", variant = ButtonVariant.Danger, onClick = onUnlinkGoogle) + } else { + TcButton("Mit Google-Konto verknüpfen", variant = ButtonVariant.Default, onClick = onLinkGoogle) + } TcButton("Speichern", variant = ButtonVariant.Primary, onClick = { onSave( fullName, diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt index d9bf43d..79f72e8 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import de.tsschulz.timeclock.data.api.InvitationDto +import de.tsschulz.timeclock.data.api.OAuthIdentityDto import de.tsschulz.timeclock.data.api.ProfileDto import de.tsschulz.timeclock.data.api.StateDto import de.tsschulz.timeclock.data.api.TimeClockApiClient @@ -28,6 +29,7 @@ data class SettingsUiState( val invites: List = emptyList(), val watchers: List = emptyList(), val googleLinkUrl: String? = null, + val oauthIdentities: List = emptyList(), ) class SettingsViewModel( @@ -41,7 +43,11 @@ class SettingsViewModel( } fun loadProfile() = launchLoad { - copy(profile = repository.getProfile(), states = repository.getStates()) + copy( + profile = repository.getProfile(), + states = repository.getStates(), + oauthIdentities = repository.getOAuthIdentities(), + ) } fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) = launchMutation("Profil gespeichert") { @@ -62,6 +68,11 @@ class SettingsViewModel( _uiState.update { it.copy(googleLinkUrl = null) } } + fun unlinkGoogle() = launchMutation("Google-Verknüpfung entfernt") { + repository.unlinkOAuthProvider("google") + loadProfile() + } + fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") { repository.changePassword(oldPassword, newPassword, confirmPassword) }