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) }