Implement Google OAuth linking functionality. Update backend to handle linking existing accounts with Google, including state token management. Enhance frontend to support linking process, including new UI components for user input and feedback. Update mobile app to handle OAuth callbacks and integrate linking features. Refactor related services and controllers for improved error handling and user experience.

This commit is contained in:
Torsten Schulz (local)
2026-05-15 08:27:36 +02:00
parent 95b611fd04
commit c16d2a6e4d
20 changed files with 768 additions and 127 deletions

View File

@@ -11,11 +11,20 @@
android:theme="@style/Theme.TimeClock">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="timeclock"
android:host="oauth-callback" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -1,11 +1,14 @@
package de.tsschulz.timeclock
import android.graphics.Color
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import de.tsschulz.timeclock.ui.TimeClockApp
@@ -17,9 +20,12 @@ import de.tsschulz.timeclock.ui.theme.TimeClockTheme
import de.tsschulz.timeclock.ui.time.TimeViewModel
class MainActivity : ComponentActivity() {
private val oauthCallback = mutableStateOf<OAuthCallback?>(null)
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
oauthCallback.value = intent.toOAuthCallback()
Log.i(TAG, "MainActivity.onCreate")
window.statusBarColor = Color.rgb(240, 255, 236)
window.navigationBarColor = Color.WHITE
@@ -30,6 +36,20 @@ class MainActivity : ComponentActivity() {
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
val callback = oauthCallback.value
LaunchedEffect(callback) {
when (callback) {
is OAuthCallback.Token -> {
authViewModel.completeOAuthToken(callback.token)
oauthCallback.value = null
}
is OAuthCallback.Pending -> {
authViewModel.requireOAuthLink(callback.pendingToken, callback.email)
oauthCallback.value = null
}
null -> Unit
}
}
TimeClockTheme {
TimeClockApp(
authViewModel = authViewModel,
@@ -58,7 +78,28 @@ class MainActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
oauthCallback.value = intent.toOAuthCallback()
}
private fun Intent?.toOAuthCallback(): OAuthCallback? {
val data = this?.data ?: return null
if (data.scheme != "timeclock" || data.host != "oauth-callback") return null
val token = data.getQueryParameter("token")
if (!token.isNullOrBlank()) return OAuthCallback.Token(token)
val pending = data.getQueryParameter("pending")
if (!pending.isNullOrBlank()) return OAuthCallback.Pending(pending, data.getQueryParameter("email"))
return null
}
private companion object {
const val TAG = "TimeClockStartup"
}
}
private sealed class OAuthCallback {
data class Token(val token: String) : OAuthCallback()
data class Pending(val pendingToken: String, val email: String?) : OAuthCallback()
}

View File

@@ -50,3 +50,22 @@ data class LogoutResponse(
val success: Boolean = true,
val message: String? = null,
)
@Serializable
data class OAuthLinkUrlRequest(
val platform: String = "android",
)
@Serializable
data class OAuthLinkUrlResponse(
val success: Boolean = false,
val url: String? = null,
val error: String? = null,
)
@Serializable
data class OAuthLinkExistingRequest(
val pendingToken: String,
val email: String,
val password: String,
)

View File

@@ -69,6 +69,32 @@ class TimeClockApiClient(
return decode(LogoutResponse.serializer(), raw.ifBlank { "{}" })
}
fun googleLoginUrl(): String = endpoint("auth/google?platform=android")
suspend fun createGoogleLinkUrl(): OAuthLinkUrlResponse {
val raw = execute(
authorized("auth/google/link-url")
.post(json.encodeToString(OAuthLinkUrlRequest.serializer(), OAuthLinkUrlRequest()).toRequestBody(JsonMedia))
.build(),
)
return decode(OAuthLinkUrlResponse.serializer(), raw)
}
suspend fun linkExistingOAuthAccount(pendingToken: String, email: String, password: String): LoginResponse {
val raw = execute(
Request.Builder()
.url(endpoint("auth/oauth/link-existing"))
.post(
json.encodeToString(
OAuthLinkExistingRequest.serializer(),
OAuthLinkExistingRequest(pendingToken, email, password),
).toRequestBody(JsonMedia),
)
.build(),
)
return decode(LoginResponse.serializer(), raw)
}
suspend fun getCurrentState(): CurrentStateResponse {
val raw = execute(authorized("time-entries/current-state").get().build())
return decode(CurrentStateResponse.serializer(), raw)

View File

@@ -63,6 +63,39 @@ class AuthRepository(
}
}
fun googleLoginUrl(): String = api.googleLoginUrl()
suspend fun createGoogleLinkUrl(): Result<String> =
try {
val res = api.createGoogleLinkUrl()
if (res.success && !res.url.isNullOrBlank()) {
Result.success(res.url)
} else {
Result.failure(Exception(res.error ?: "Google-Verknüpfung konnte nicht gestartet werden"))
}
} catch (e: Exception) {
Result.failure(e)
}
suspend fun completeOAuthLogin(token: String): Result<UserProfile> {
tokenStore.saveToken(token)
val user = restoreSession()
return if (user != null) Result.success(user) else Result.failure(Exception("Google-Login konnte nicht abgeschlossen werden"))
}
suspend fun linkExistingOAuthAccount(pendingToken: String, email: String, password: String): Result<UserProfile> =
try {
val res = api.linkExistingOAuthAccount(pendingToken, email.trim(), password)
if (res.success && !res.token.isNullOrBlank() && res.user != null) {
tokenStore.saveToken(res.token)
Result.success(res.user.toProfile())
} else {
Result.failure(Exception(res.error ?: "Google-Verknüpfung fehlgeschlagen"))
}
} catch (e: Exception) {
Result.failure(e)
}
suspend fun logout() {
try {
api.postLogout()

View File

@@ -20,6 +20,14 @@ class SettingsRepository(
suspend fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) =
api.updateProfile(ProfileUpdateRequest(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType))
suspend fun createGoogleLinkUrl(): String {
val response = api.createGoogleLinkUrl()
if (!response.success || response.url.isNullOrBlank()) {
throw IllegalStateException(response.error ?: "Google-Verknüpfung konnte nicht gestartet werden")
}
return response.url
}
suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) =
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))

View File

@@ -1,5 +1,7 @@
package de.tsschulz.timeclock.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -19,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -77,12 +80,24 @@ fun TimeClockApp(
val bookingState by bookingViewModel.uiState.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val adminState by adminViewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val openUrl: (String) -> Unit = { url ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
LaunchedEffect(settingsState.googleLinkUrl) {
settingsState.googleLinkUrl?.let {
openUrl(it)
settingsViewModel.consumeGoogleLinkUrl()
}
}
if (!authState.isAuthenticated) {
LaunchedEffect(Unit) { timeViewModel.stop() }
LoginScreen(
state = authState,
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
onGoogleLogin = { openUrl(authViewModel.googleLoginUrl()) },
onLinkExistingGoogleAccount = { e, p -> authViewModel.linkExistingOAuthAccount(e, p) },
onRetryBootstrap = { authViewModel.retryBootstrap() },
)
return
@@ -93,6 +108,8 @@ fun TimeClockApp(
LoginScreen(
state = authState,
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
onGoogleLogin = { openUrl(authViewModel.googleLoginUrl()) },
onLinkExistingGoogleAccount = { e, p -> authViewModel.linkExistingOAuthAccount(e, p) },
onRetryBootstrap = { authViewModel.retryBootstrap() },
)
return
@@ -238,6 +255,7 @@ private fun DemoScreen(
onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
},
onLinkGoogle = { settingsViewModel.startGoogleLink() },
)
}
AppRoute.Password -> PasswordScreen(

View File

@@ -20,6 +20,9 @@ data class AuthUiState(
val user: UserProfile? = null,
val loginInProgress: Boolean = false,
val error: String? = null,
val googleLoginUrl: String? = null,
val oauthPendingToken: String? = null,
val oauthPendingEmail: String? = null,
/** Gespeicherter Token, aber `/auth/me` ist fehlgeschlagen (z. B. offline). */
val bootstrapWarn: String? = null,
)
@@ -35,6 +38,8 @@ class AuthViewModel(
viewModelScope.launch { runBootstrap() }
}
fun googleLoginUrl(): String = repository.googleLoginUrl()
private suspend fun runBootstrap() {
val user = repository.restoreSession()
val warn = if (user == null && repository.hasStoredToken()) {
@@ -88,6 +93,69 @@ class AuthViewModel(
}
}
fun completeOAuthToken(token: String) {
viewModelScope.launch {
_uiState.update { it.copy(loginInProgress = true, error = null) }
repository.completeOAuthLogin(token).fold(
onSuccess = { user ->
_uiState.update {
it.copy(
loginInProgress = false,
isAuthenticated = true,
user = user,
error = null,
oauthPendingToken = null,
oauthPendingEmail = null,
bootstrapWarn = null,
)
}
},
onFailure = { e ->
_uiState.update { it.copy(loginInProgress = false, error = e.message ?: "Google-Login fehlgeschlagen") }
},
)
}
}
fun requireOAuthLink(pendingToken: String, email: String?) {
_uiState.update {
it.copy(
bootstrapping = false,
loginInProgress = false,
oauthPendingToken = pendingToken,
oauthPendingEmail = email,
error = null,
)
}
}
fun linkExistingOAuthAccount(email: String, password: String) {
val pendingToken = _uiState.value.oauthPendingToken
if (pendingToken.isNullOrBlank()) {
_uiState.update { it.copy(error = "Keine Google-Verknüpfung offen") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(loginInProgress = true, error = null) }
repository.linkExistingOAuthAccount(pendingToken, email, password).fold(
onSuccess = { user ->
_uiState.update {
it.copy(
loginInProgress = false,
isAuthenticated = true,
user = user,
oauthPendingToken = null,
oauthPendingEmail = null,
)
}
},
onFailure = { e ->
_uiState.update { it.copy(loginInProgress = false, error = e.message ?: "Google-Verknüpfung fehlgeschlagen") }
},
)
}
}
fun retryBootstrap() {
viewModelScope.launch {
_uiState.update { it.copy(bootstrapping = true, bootstrapWarn = null) }

View File

@@ -49,11 +49,15 @@ import de.tsschulz.timeclock.ui.theme.TcSpacing
fun LoginScreen(
state: AuthUiState,
onLogin: (email: String, password: String, action: String) -> Unit,
onGoogleLogin: () -> Unit,
onLinkExistingGoogleAccount: (email: String, password: String) -> Unit,
onRetryBootstrap: () -> Unit,
modifier: Modifier = Modifier,
) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var linkEmail by rememberSaveable { mutableStateOf("") }
var linkPassword by rememberSaveable { mutableStateOf("") }
BoxWithConstraints(
modifier = modifier
@@ -139,45 +143,79 @@ fun LoginScreen(
state.error?.let { AuthErrorBanner(message = it) }
AuthFormRow(
label = "E-Mail-Adresse",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = email,
onValueChange = { email = it },
placeholder = "Ihre E-Mail-Adresse eingeben",
showLabel = false,
if (!state.oauthPendingToken.isNullOrBlank()) {
if (linkEmail.isBlank()) linkEmail = state.oauthPendingEmail.orEmpty()
Text(
text = "Für ${state.oauthPendingEmail ?: "dieses Google-Konto"} existiert bereits ein Account. Melden Sie sich mit dem bestehenden Account an, um ihn zu verknüpfen.",
color = TcColors.Text,
fontSize = 14.sp,
)
}
AuthFormRow(
label = "Passwort",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = password,
onValueChange = { password = it },
placeholder = "Ihr Passwort eingeben",
isPassword = true,
showLabel = false,
)
}
AuthFormRow(label = "E-Mail-Adresse", horizontal = useWideFormRows) {
TcTextField(label = "", value = linkEmail, onValueChange = { linkEmail = it }, showLabel = false)
}
AuthFormRow(label = "Passwort", horizontal = useWideFormRows) {
TcTextField(label = "", value = linkPassword, onValueChange = { linkPassword = it }, isPassword = true, showLabel = false)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
TcButton(
text = if (state.loginInProgress) "Wird verknüpft…" else "Bestehenden Account verknüpfen",
variant = ButtonVariant.Primary,
onClick = { onLinkExistingGoogleAccount(linkEmail, linkPassword) },
)
}
} else {
AuthFormRow(
label = "E-Mail-Adresse",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = email,
onValueChange = { email = it },
placeholder = "Ihre E-Mail-Adresse eingeben",
showLabel = false,
)
}
AuthFormRow(
label = "Passwort",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = password,
onValueChange = { password = it },
placeholder = "Ihr Passwort eingeben",
isPassword = true,
showLabel = false,
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TcButton(
text = if (state.loginInProgress) "Wird eingeloggt…" else "Einloggen",
variant = ButtonVariant.Primary,
onClick = { onLogin(email, password, "0") },
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TcButton(
text = if (state.loginInProgress) "Wird eingeloggt…" else "Einloggen",
variant = ButtonVariant.Primary,
onClick = { onLogin(email, password, "0") },
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
)
}
OAuthDivider()
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
TcButton(
text = "Mit Google anmelden",
variant = ButtonVariant.Default,
onClick = onGoogleLogin,
modifier = Modifier.defaultMinSize(minWidth = 220.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
)
}
}
}
}
@@ -188,6 +226,19 @@ fun LoginScreen(
}
}
@Composable
private fun OAuthDivider() {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
HorizontalDivider(modifier = Modifier.weight(1f), color = TcColors.Border)
Text("oder", color = TcColors.DividerText, fontSize = 14.sp)
HorizontalDivider(modifier = Modifier.weight(1f), color = TcColors.Border)
}
}
@Composable
private fun LoginTopBar() {
Row(

View File

@@ -49,6 +49,7 @@ fun ProfileScreen(
state: SettingsUiState,
isTablet: Boolean,
onSave: (String, String?, Int, Double, Int) -> Unit,
onLinkGoogle: () -> Unit,
) {
val profile = state.profile
var fullName by rememberSaveable { mutableStateOf("") }
@@ -80,6 +81,8 @@ fun ProfileScreen(
TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5")
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)
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
onSave(
fullName,

View File

@@ -27,6 +27,7 @@ data class SettingsUiState(
val timewishes: List<TimewishDto> = emptyList(),
val invites: List<InvitationDto> = emptyList(),
val watchers: List<WatcherDto> = emptyList(),
val googleLinkUrl: String? = null,
)
class SettingsViewModel(
@@ -48,6 +49,19 @@ class SettingsViewModel(
loadProfile()
}
fun startGoogleLink() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, googleLinkUrl = null) }
runCatching { repository.createGoogleLinkUrl() }
.onSuccess { url -> _uiState.update { it.copy(loading = false, googleLinkUrl = url) } }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Google-Verknüpfung konnte nicht gestartet werden") } }
}
}
fun consumeGoogleLinkUrl() {
_uiState.update { it.copy(googleLinkUrl = null) }
}
fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") {
repository.changePassword(oldPassword, newPassword, confirmPassword)
}