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:
4
.google.oauth
Normal file
4
.google.oauth
Normal 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"]}}
|
||||||
@@ -89,9 +89,15 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Google-Anmeldung</label>
|
<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
|
Mit Google-Konto verknüpfen
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else type="button" class="btn btn-danger" @click="unlinkGoogle" :disabled="loading">
|
||||||
|
Google-Verknüpfung entfernen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -118,7 +124,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
import { useModal } from '../composables/useModal'
|
import { useModal } from '../composables/useModal'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
@@ -127,6 +133,7 @@ import { API_BASE_URL } from '@/config/api'
|
|||||||
const API_URL = API_BASE_URL
|
const API_URL = API_BASE_URL
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const availableStates = ref([])
|
const availableStates = ref([])
|
||||||
|
const identities = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal()
|
const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal()
|
||||||
@@ -140,6 +147,8 @@ const form = ref({
|
|||||||
preferredTitleType: 2
|
preferredTitleType: 2
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const googleLinked = computed(() => identities.value.some(identity => identity.provider === 'google'))
|
||||||
|
|
||||||
// Lade Profil
|
// Lade Profil
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
try {
|
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
|
// Speichere Profil
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
try {
|
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
|
// Initiales Laden
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadStates(),
|
loadStates(),
|
||||||
loadProfile()
|
loadProfile(),
|
||||||
|
loadIdentities()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -302,6 +355,20 @@ onMounted(async () => {
|
|||||||
cursor: not-allowed;
|
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 input:focus,
|
||||||
.form-group select:focus {
|
.form-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.tsschulz.timeclock"
|
applicationId = "de.tsschulz.timeclock"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 4
|
versionCode = 5
|
||||||
versionName = "0.8.0-alpha3"
|
versionName = "0.8.0-alpha4"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ data class PasswordChangeRequest(
|
|||||||
val confirmPassword: String,
|
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
|
@Serializable
|
||||||
data class TimewishDto(
|
data class TimewishDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ class TimeClockApiClient(
|
|||||||
return decode(OAuthLinkUrlResponse.serializer(), raw)
|
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 {
|
suspend fun linkExistingOAuthAccount(pendingToken: String, email: String, password: String): LoginResponse {
|
||||||
val raw = execute(
|
val raw = execute(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.tsschulz.timeclock.data.settings
|
|||||||
|
|
||||||
import de.tsschulz.timeclock.data.api.InvitationDto
|
import de.tsschulz.timeclock.data.api.InvitationDto
|
||||||
import de.tsschulz.timeclock.data.api.InviteRequest
|
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.PasswordChangeRequest
|
||||||
import de.tsschulz.timeclock.data.api.ProfileDto
|
import de.tsschulz.timeclock.data.api.ProfileDto
|
||||||
import de.tsschulz.timeclock.data.api.ProfileUpdateRequest
|
import de.tsschulz.timeclock.data.api.ProfileUpdateRequest
|
||||||
@@ -28,6 +29,10 @@ class SettingsRepository(
|
|||||||
return response.url
|
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) =
|
suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) =
|
||||||
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))
|
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ private fun DemoScreen(
|
|||||||
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
|
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
|
||||||
},
|
},
|
||||||
onLinkGoogle = { settingsViewModel.startGoogleLink() },
|
onLinkGoogle = { settingsViewModel.startGoogleLink() },
|
||||||
|
onUnlinkGoogle = { settingsViewModel.unlinkGoogle() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AppRoute.Password -> PasswordScreen(
|
AppRoute.Password -> PasswordScreen(
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ fun ProfileScreen(
|
|||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
onSave: (String, String?, Int, Double, Int) -> Unit,
|
onSave: (String, String?, Int, Double, Int) -> Unit,
|
||||||
onLinkGoogle: () -> Unit,
|
onLinkGoogle: () -> Unit,
|
||||||
|
onUnlinkGoogle: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val profile = state.profile
|
val profile = state.profile
|
||||||
var fullName by rememberSaveable { mutableStateOf("") }
|
var fullName by rememberSaveable { mutableStateOf("") }
|
||||||
@@ -82,7 +83,18 @@ fun ProfileScreen(
|
|||||||
TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0")
|
TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0")
|
||||||
TitleTypeDropdown(preferredTitleType, { preferredTitleType = it })
|
TitleTypeDropdown(preferredTitleType, { preferredTitleType = it })
|
||||||
FieldLabel("Google-Anmeldung")
|
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("Mit Google-Konto verknüpfen", variant = ButtonVariant.Default, onClick = onLinkGoogle)
|
||||||
|
}
|
||||||
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
|
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
|
||||||
onSave(
|
onSave(
|
||||||
fullName,
|
fullName,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import de.tsschulz.timeclock.data.api.InvitationDto
|
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.ProfileDto
|
||||||
import de.tsschulz.timeclock.data.api.StateDto
|
import de.tsschulz.timeclock.data.api.StateDto
|
||||||
import de.tsschulz.timeclock.data.api.TimeClockApiClient
|
import de.tsschulz.timeclock.data.api.TimeClockApiClient
|
||||||
@@ -28,6 +29,7 @@ data class SettingsUiState(
|
|||||||
val invites: List<InvitationDto> = emptyList(),
|
val invites: List<InvitationDto> = emptyList(),
|
||||||
val watchers: List<WatcherDto> = emptyList(),
|
val watchers: List<WatcherDto> = emptyList(),
|
||||||
val googleLinkUrl: String? = null,
|
val googleLinkUrl: String? = null,
|
||||||
|
val oauthIdentities: List<OAuthIdentityDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
@@ -41,7 +43,11 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadProfile() = launchLoad {
|
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") {
|
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) }
|
_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") {
|
fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") {
|
||||||
repository.changePassword(oldPassword, newPassword, confirmPassword)
|
repository.changePassword(oldPassword, newPassword, confirmPassword)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user