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