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.

This commit is contained in:
Torsten Schulz (local)
2026-05-15 09:06:31 +02:00
parent c16d2a6e4d
commit 52719d5625
9 changed files with 129 additions and 7 deletions

4
.google.oauth Normal file
View File

@@ -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"]}}

View File

@@ -89,9 +89,15 @@
<div class="form-group">
<label>Google-Anmeldung</label>
<button type="button" class="btn" @click="linkGoogle" :disabled="loading">
<div class="oauth-status" :class="{ linked: googleLinked }">
{{ googleLinked ? 'Google-Konto ist verknüpft' : 'Kein Google-Konto verknüpft' }}
</div>
<button v-if="!googleLinked" type="button" class="btn" @click="linkGoogle" :disabled="loading">
Mit Google-Konto verknüpfen
</button>
<button v-else type="button" class="btn btn-danger" @click="unlinkGoogle" :disabled="loading">
Google-Verknüpfung entfernen
</button>
</div>
<div class="form-actions">
@@ -118,7 +124,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/authStore'
import { useModal } from '../composables/useModal'
import Modal from '../components/Modal.vue'
@@ -127,6 +133,7 @@ import { API_BASE_URL } from '@/config/api'
const API_URL = API_BASE_URL
const authStore = useAuthStore()
const availableStates = ref([])
const identities = ref([])
const loading = ref(false)
const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal()
@@ -140,6 +147,8 @@ const form = ref({
preferredTitleType: 2
})
const googleLinked = computed(() => identities.value.some(identity => identity.provider === 'google'))
// Lade Profil
async function loadProfile() {
try {
@@ -190,6 +199,23 @@ async function loadStates() {
}
}
async function loadIdentities() {
try {
const response = await fetch(`${API_URL}/auth/identities`, {
headers: authStore.getAuthHeaders()
})
if (!response.ok) {
throw new Error('Fehler beim Laden der Google-Verknüpfung')
}
const result = await response.json()
identities.value = result.identities || []
} catch (error) {
console.error('Fehler beim Laden der OAuth-Verknüpfungen:', error)
}
}
// Speichere Profil
async function saveProfile() {
try {
@@ -246,11 +272,38 @@ async function linkGoogle() {
}
}
async function unlinkGoogle() {
const confirmed = await confirm(
'Möchten Sie die Google-Verknüpfung wirklich entfernen?',
'Google-Verknüpfung entfernen'
)
if (!confirmed) return
try {
loading.value = true
const response = await fetch(`${API_URL}/auth/identity/google`, {
method: 'DELETE',
headers: authStore.getAuthHeaders()
})
const result = await response.json()
if (!response.ok || !result.success) {
throw new Error(result.error || 'Google-Verknüpfung konnte nicht entfernt werden')
}
await loadIdentities()
await alert('Google-Verknüpfung wurde entfernt', 'Erfolg')
} catch (error) {
await alert(`Fehler: ${error.message}`, 'Fehler')
} finally {
loading.value = false
}
}
// Initiales Laden
onMounted(async () => {
await Promise.all([
loadStates(),
loadProfile()
loadProfile(),
loadIdentities()
])
})
</script>
@@ -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;

View File

@@ -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("\"", "\\\"")}\"")
}

View File

@@ -36,6 +36,20 @@ data class PasswordChangeRequest(
val confirmPassword: String,
)
@Serializable
data class OAuthIdentitiesResponse(
val success: Boolean = false,
val identities: List<OAuthIdentityDto> = 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,

View File

@@ -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()

View File

@@ -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<OAuthIdentityDto> = 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))

View File

@@ -256,6 +256,7 @@ private fun DemoScreen(
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
},
onLinkGoogle = { settingsViewModel.startGoogleLink() },
onUnlinkGoogle = { settingsViewModel.unlinkGoogle() },
)
}
AppRoute.Password -> PasswordScreen(

View File

@@ -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")
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,

View File

@@ -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<InvitationDto> = emptyList(),
val watchers: List<WatcherDto> = emptyList(),
val googleLinkUrl: String? = null,
val oauthIdentities: List<OAuthIdentityDto> = 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)
}