Feature: Self-Hosted Server URL configuration & Authentik OIDC support
- DynamicBaseUrlInterceptor: dynamically rewrites Retrofit HTTP requests to custom server URL - SessionManager: persist custom server_url setting (e.g. https://mitbringsl.mydomain.com) - AuthScreen & ViewModel: add Server URL chip & edit dialog + Authentik / OIDC login mode - NetworkModule: inject SessionManager into DynamicBaseUrlInterceptor & AuthInterceptor - ADB reinstall & launch verified ✅
This commit is contained in:
parent
04e5c55c2a
commit
47b24dfcdf
6 changed files with 284 additions and 95 deletions
|
|
@ -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<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +195,24 @@ fun AuthScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (uiState.isOidcMode) {
|
||||
// OIDC / Authentik Token Field
|
||||
OutlinedTextField(
|
||||
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(
|
||||
|
|
@ -216,6 +263,7 @@ fun AuthScreen(
|
|||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
|
|
@ -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,18 +300,34 @@ fun AuthScreen(
|
|||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Toggle Login / Register mode
|
||||
// Toggle Buttons (E-Mail vs OIDC / Authentik)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = viewModel::toggleMode) {
|
||||
Text(
|
||||
text = if (uiState.isRegisteringMode) {
|
||||
"Bereits ein Konto? Hier anmelden"
|
||||
} else {
|
||||
"Noch kein Konto? Hier registrieren"
|
||||
},
|
||||
text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuthUiState> = _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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue