diff --git a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt index a616d36..1a58144 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt @@ -2,20 +2,16 @@ package com.example.mitbringsl.data.auth import android.content.Context import androidx.core.content.edit +import com.example.mitbringsl.BuildConfig import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton /** - * Manages the user's session state and local device identification. - * - * Local-First design: Users do NOT need an account to use the app locally. - * When not logged in, [getUserId] returns a stable local ID ("local_user"). - * Logging in is 100% optional and only required to sync across devices. + * Manages the user's session state, token storage, and self-hosted server URL. */ @Singleton class SessionManager @Inject constructor( @@ -30,11 +26,26 @@ class SessionManager @Inject constructor( private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank()) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + private val _serverUrl = MutableStateFlow(getServerUrl()) + val serverUrl: StateFlow = _serverUrl.asStateFlow() + + fun getServerUrl(): String { + return prefs.getString("server_url", null) ?: BuildConfig.BASE_URL + } + + fun saveServerUrl(url: String) { + val cleanUrl = if (!url.startsWith("http://") && !url.startsWith("https://")) { + "https://$url" + } else { + url + }.trim().trimEnd('/') + + prefs.edit { putString("server_url", cleanUrl) } + _serverUrl.value = cleanUrl + } + fun getToken(): String? = prefs.getString("token", null) - /** - * Returns the authenticated user's ID if logged in, or [LOCAL_USER_ID] for local-only mode. - */ fun getUserId(): String { val remoteId = prefs.getString("user_id", null) if (!remoteId.isNullOrBlank()) return remoteId diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/AuthInterceptor.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/AuthInterceptor.kt index 9dc4bcf..d816093 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/AuthInterceptor.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/AuthInterceptor.kt @@ -1,18 +1,16 @@ package com.example.mitbringsl.data.remote.api +import com.example.mitbringsl.data.auth.SessionManager import okhttp3.Interceptor import okhttp3.Response /** * OkHttp interceptor that injects the session Bearer token into every request. - * - * [tokenProvider] is a lambda so the interceptor always reads the most recent - * token without needing to be recreated after login. */ -class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { +class AuthInterceptor(private val sessionManager: SessionManager) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val token = tokenProvider() + val token = sessionManager.getToken() val request = if (token.isNullOrBlank()) { chain.request() } else { diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/DynamicBaseUrlInterceptor.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/DynamicBaseUrlInterceptor.kt new file mode 100644 index 0000000..a80d4fb --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/DynamicBaseUrlInterceptor.kt @@ -0,0 +1,37 @@ +package com.example.mitbringsl.data.remote.api + +import com.example.mitbringsl.data.auth.SessionManager +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Interceptor that dynamically redirects requests to the user's configured self-hosted server URL. + */ +class DynamicBaseUrlInterceptor( + private val sessionManager: SessionManager +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val customServerUrl = sessionManager.getServerUrl().trim().trimEnd('/') + + if (customServerUrl.isBlank()) { + return chain.proceed(original) + } + + val targetUrl = customServerUrl.toHttpUrlOrNull() ?: return chain.proceed(original) + + val newUrl = original.url.newBuilder() + .scheme(targetUrl.scheme) + .host(targetUrl.host) + .port(targetUrl.port) + .build() + + val newRequest = original.newBuilder() + .url(newUrl) + .build() + + return chain.proceed(newRequest) + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/di/NetworkModule.kt b/android/app/src/main/java/com/example/mitbringsl/di/NetworkModule.kt index b265083..b57468d 100644 --- a/android/app/src/main/java/com/example/mitbringsl/di/NetworkModule.kt +++ b/android/app/src/main/java/com/example/mitbringsl/di/NetworkModule.kt @@ -1,13 +1,13 @@ package com.example.mitbringsl.di -import android.content.Context import com.example.mitbringsl.BuildConfig +import com.example.mitbringsl.data.auth.SessionManager import com.example.mitbringsl.data.remote.api.AuthInterceptor +import com.example.mitbringsl.data.remote.api.DynamicBaseUrlInterceptor import com.example.mitbringsl.data.remote.api.MitbringslApi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -21,10 +21,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - /** - * Lax JSON decoder: ignores unknown keys so the app doesn't crash when the - * backend adds new fields, and handles missing optional fields gracefully. - */ @Provides @Singleton fun provideJson(): Json = Json { @@ -35,15 +31,7 @@ object NetworkModule { @Provides @Singleton - fun provideTokenProvider(@ApplicationContext context: Context): () -> String? = { - // Read token from SharedPreferences (written after login). - context.getSharedPreferences("session", Context.MODE_PRIVATE) - .getString("token", null) - } - - @Provides - @Singleton - fun provideOkHttpClient(tokenProvider: () -> String?): OkHttpClient { + fun provideOkHttpClient(sessionManager: SessionManager): OkHttpClient { val logging = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY @@ -52,7 +40,8 @@ object NetworkModule { } } return OkHttpClient.Builder() - .addInterceptor(AuthInterceptor(tokenProvider)) + .addInterceptor(DynamicBaseUrlInterceptor(sessionManager)) + .addInterceptor(AuthInterceptor(sessionManager)) .addInterceptor(logging) .build() } diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt index 8116bc7..509ef3b 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt @@ -10,9 +10,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Key import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -36,8 +38,11 @@ fun AuthScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() + val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle() val userEmail = viewModel.sessionManager.getUserEmail() + var showServerUrlDialog by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current val gradientBrush = Brush.verticalGradient( colors = listOf( @@ -59,7 +64,7 @@ fun AuthScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(24.dp)) // App Logo / Title Header Surface( @@ -83,13 +88,35 @@ fun AuthScreen( ) Text( - text = "Melde dich an, um deine Listen geräteübergreifend zu synchronisieren.", + text = "Verbinde dich mit deinem Server oder Authentik für geräteübergreifenden Sync.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(16.dp)) + + // Server URL Chip + AssistChip( + onClick = { showServerUrlDialog = true }, + label = { + Text( + text = "Server: $currentServerUrl", + style = MaterialTheme.typography.labelSmall + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Dns, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + shape = RoundedCornerShape(20.dp) + ) + + Spacer(modifier = Modifier.height(20.dp)) // Auth Card Card( @@ -120,9 +147,7 @@ fun AuthScreen( Spacer(modifier = Modifier.height(24.dp)) Button( - onClick = { - viewModel.sessionManager.clearSession() - }, + onClick = { viewModel.sessionManager.clearSession() }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ), @@ -133,7 +158,11 @@ fun AuthScreen( } } else { Text( - text = if (uiState.isRegisteringMode) "Konto erstellen" else "Anmelden", + text = when { + uiState.isOidcMode -> "Authentik / OIDC Login" + uiState.isRegisteringMode -> "Konto erstellen" + else -> "Anmelden" + }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -166,57 +195,76 @@ fun AuthScreen( } } - // Display Name (only in register mode) - if (uiState.isRegisteringMode) { + if (uiState.isOidcMode) { + // OIDC / Authentik Token Field OutlinedTextField( - value = uiState.displayNameInput, - onValueChange = viewModel::onDisplayNameChanged, - label = { Text("Anzeigename (optional)") }, + value = uiState.oidcTokenInput, + onValueChange = viewModel::onOidcTokenInputChanged, + label = { Text("OIDC ID-Token (JWT)") }, + placeholder = { Text("eyJhbGci...") }, + singleLine = false, + maxLines = 4, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + focusManager.clearFocus() + viewModel.submit() + }) + ) + } else { + // Display Name (only in register mode) + if (uiState.isRegisteringMode) { + OutlinedTextField( + value = uiState.displayNameInput, + onValueChange = viewModel::onDisplayNameChanged, + label = { Text("Anzeigename (optional)") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + // Email Field + OutlinedTextField( + value = uiState.emailInput, + onValueChange = viewModel::onEmailChanged, + label = { Text("E-Mail Adresse") }, singleLine = true, shape = RoundedCornerShape(16.dp), modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) ) + Spacer(modifier = Modifier.height(12.dp)) + + // Password Field + OutlinedTextField( + value = uiState.passwordInput, + onValueChange = viewModel::onPasswordChanged, + label = { Text("Passwort") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + viewModel.submit() + } + ) + ) } - // Email Field - OutlinedTextField( - value = uiState.emailInput, - onValueChange = viewModel::onEmailChanged, - label = { Text("E-Mail Adresse") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next - ) - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // Password Field - OutlinedTextField( - value = uiState.passwordInput, - onValueChange = viewModel::onPasswordChanged, - label = { Text("Passwort") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - viewModel.submit() - } - ) - ) - Spacer(modifier = Modifier.height(24.dp)) // Submit Button @@ -239,7 +287,11 @@ fun AuthScreen( ) } else { Text( - text = if (uiState.isRegisteringMode) "Registrieren" else "Anmelden", + text = when { + uiState.isOidcMode -> "Mit Authentik / OIDC verbinden" + uiState.isRegisteringMode -> "Registrieren" + else -> "Anmelden" + }, fontWeight = FontWeight.Bold, fontSize = 16.sp ) @@ -248,17 +300,33 @@ fun AuthScreen( Spacer(modifier = Modifier.height(12.dp)) - // Toggle Login / Register mode - TextButton(onClick = viewModel::toggleMode) { - Text( - text = if (uiState.isRegisteringMode) { - "Bereits ein Konto? Hier anmelden" - } else { - "Noch kein Konto? Hier registrieren" - }, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyMedium - ) + // Toggle Buttons (E-Mail vs OIDC / Authentik) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = viewModel::toggleMode) { + Text( + text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall + ) + } + + TextButton(onClick = viewModel::toggleOidcMode) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (uiState.isOidcMode) "Passwort Login" else "Authentik / OIDC", + style = MaterialTheme.typography.bodySmall + ) + } + } } } @@ -276,4 +344,52 @@ fun AuthScreen( Spacer(modifier = Modifier.height(32.dp)) } } + + // Server URL Dialog + if (showServerUrlDialog) { + var tempUrl by remember { mutableStateOf(currentServerUrl) } + + AlertDialog( + onDismissRequest = { showServerUrlDialog = false }, + title = { Text("Self-Hosted Server URL") }, + text = { + Column { + Text( + text = "Gib die URL deines Mitbringsl Backend-Servers ein:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = tempUrl, + onValueChange = { tempUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("https://mitbringsl.example.com") }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + Button( + onClick = { + if (tempUrl.isNotBlank()) { + viewModel.saveServerUrl(tempUrl) + showServerUrlDialog = false + } + }, + shape = RoundedCornerShape(12.dp) + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = { showServerUrlDialog = false }) { + Text("Abbrechen") + } + }, + shape = RoundedCornerShape(24.dp) + ) + } } diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt index 48de57f..509eb1b 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt @@ -17,9 +17,12 @@ data class AuthUiState( val emailInput: String = "", val passwordInput: String = "", val displayNameInput: String = "", + val oidcTokenInput: String = "", val isRegisteringMode: Boolean = false, + val isOidcMode: Boolean = false, val isLoading: Boolean = false, val errorMessage: String? = null, + val serverUrlInput: String = "", ) @HiltViewModel @@ -28,9 +31,20 @@ class AuthViewModel @Inject constructor( val sessionManager: SessionManager, ) : ViewModel() { - private val _uiState = MutableStateFlow(AuthUiState()) + private val _uiState = MutableStateFlow( + AuthUiState(serverUrlInput = sessionManager.getServerUrl()) + ) val uiState: StateFlow = _uiState.asStateFlow() + fun onServerUrlChanged(value: String) { + _uiState.update { it.copy(serverUrlInput = value) } + } + + fun saveServerUrl(url: String) { + sessionManager.saveServerUrl(url) + _uiState.update { it.copy(serverUrlInput = sessionManager.getServerUrl(), errorMessage = null) } + } + fun onEmailChanged(value: String) { _uiState.update { it.copy(emailInput = value, errorMessage = null) } } @@ -43,10 +57,24 @@ class AuthViewModel @Inject constructor( _uiState.update { it.copy(displayNameInput = value, errorMessage = null) } } + fun onOidcTokenInputChanged(value: String) { + _uiState.update { it.copy(oidcTokenInput = value, errorMessage = null) } + } + fun toggleMode() { _uiState.update { it.copy( isRegisteringMode = !it.isRegisteringMode, + isOidcMode = false, + errorMessage = null + ) + } + } + + fun toggleOidcMode() { + _uiState.update { + it.copy( + isOidcMode = !it.isOidcMode, errorMessage = null ) } @@ -54,6 +82,16 @@ class AuthViewModel @Inject constructor( fun submit() { val state = _uiState.value + + if (state.isOidcMode) { + if (state.oidcTokenInput.isBlank()) { + _uiState.update { it.copy(errorMessage = "Bitte ID-Token eingeben.") } + return + } + onOidcTokenReceived(provider = "generic", idToken = state.oidcTokenInput.trim()) + return + } + if (state.emailInput.isBlank() || state.passwordInput.isBlank()) { _uiState.update { it.copy(errorMessage = "Bitte E-Mail und Passwort ausfüllen.") } return