Feature: Server-driven auth method discovery + OIDC-only enforcement
Backend:
- New AUTH_PASSWORD_ENABLED flag (default true). When false, email/password
registration and login return 403; the server enforces OIDC-only login.
- New OIDC_GENERIC_DISPLAY_NAME so the app can show 'Authentik'/'Keycloak'
instead of a generic 'OIDC' label.
- New public endpoint GET /api/config returns which auth methods the
server offers (password_enabled + per-provider OIDC capabilities).
No auth required, so the login screen can query it before logging in.
- .env.example and docker-compose.yml expose the new env vars.
App:
- DTOs + MitbringslApi.getServerConfig() for /api/config.
- AuthViewModel: new 'connect' flow. The user enters the server URL,
taps 'Verbinden', and the app fetches /api/config. The returned
ServerAuthConfig drives which login options are shown:
* password-only -> email/password form
* OIDC-only -> OIDC token form
* both -> toggle between the two
If the server offers no method, a clear error is shown.
- AuthScreen: split into ConnectView (server URL) and LoginView (the
login form matching the server's capabilities). The mode toggle only
appears when the server offers more than one method.
This commit is contained in:
parent
b44bc8c3af
commit
3f187f1ede
10 changed files with 577 additions and 244 deletions
|
|
@ -11,6 +11,10 @@ interface MitbringslApi {
|
|||
|
||||
// --- Auth ---------------------------------------------------------------
|
||||
|
||||
/** Public server config: which auth methods are available. No auth required. */
|
||||
@GET("api/config")
|
||||
suspend fun getServerConfig(): Response<ServerConfigDto>
|
||||
|
||||
@POST("auth/register")
|
||||
suspend fun register(@Body body: RegisterRequestDto): Response<AuthResponseDto>
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,35 @@ data class ServerOpDto(
|
|||
@SerialName("created_at") val createdAt: String,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server config (public, used by the login screen to decide which auth
|
||||
// methods to offer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Serializable
|
||||
data class ServerConfigDto(
|
||||
val auth: AuthConfigDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthConfigDto(
|
||||
@SerialName("password_enabled") val passwordEnabled: Boolean,
|
||||
val oidc: OidcProvidersDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OidcProvidersDto(
|
||||
val google: OidcProviderDto,
|
||||
val generic: OidcProviderDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OidcProviderDto(
|
||||
val enabled: Boolean,
|
||||
val issuer: String? = null,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggestions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -41,8 +41,6 @@ fun AuthScreen(
|
|||
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(
|
||||
|
|
@ -88,7 +86,7 @@ fun AuthScreen(
|
|||
)
|
||||
|
||||
Text(
|
||||
text = "Verbinde dich mit deinem Server oder Authentik für geräteübergreifenden Sync.",
|
||||
text = "Verbinde dich mit deinem Server für geräteübergreifenden Sync.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
|
|
@ -98,7 +96,10 @@ fun AuthScreen(
|
|||
|
||||
// Server URL Chip
|
||||
AssistChip(
|
||||
onClick = { showServerUrlDialog = true },
|
||||
onClick = {
|
||||
// Reset back to the connect screen to re-configure the server.
|
||||
viewModel.onServerUrlChanged(currentServerUrl)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "Server: $currentServerUrl",
|
||||
|
|
@ -132,201 +133,15 @@ fun AuthScreen(
|
|||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isLoggedIn) {
|
||||
Text(
|
||||
text = "Angemeldet als",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = userEmail ?: "Benutzer",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.sessionManager.clearSession() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Abmelden")
|
||||
}
|
||||
LoggedInView(userEmail, viewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = when {
|
||||
uiState.isOidcMode -> "Authentik / OIDC Login"
|
||||
uiState.isRegisteringMode -> "Konto erstellen"
|
||||
else -> "Anmelden"
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Error Message
|
||||
AnimatedVisibility(
|
||||
visible = uiState.errorMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = msg,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
)
|
||||
val cfg = uiState.serverConfig
|
||||
if (cfg == null) {
|
||||
// Step 1: connect to the server first.
|
||||
ConnectView(uiState, viewModel, focusManager)
|
||||
} 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(
|
||||
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
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.submit()
|
||||
},
|
||||
enabled = !uiState.isLoading,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = when {
|
||||
uiState.isOidcMode -> "Mit Authentik / OIDC verbinden"
|
||||
uiState.isRegisteringMode -> "Registrieren"
|
||||
else -> "Anmelden"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
// Step 2: login with the methods the server offers.
|
||||
LoginView(uiState, viewModel, cfg, focusManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -344,52 +159,310 @@ fun AuthScreen(
|
|||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server URL Dialog
|
||||
if (showServerUrlDialog) {
|
||||
var tempUrl by remember { mutableStateOf(currentServerUrl) }
|
||||
@Composable
|
||||
private fun LoggedInView(
|
||||
userEmail: String?,
|
||||
viewModel: AuthViewModel,
|
||||
) {
|
||||
Text(
|
||||
text = "Angemeldet als",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = userEmail ?: "Benutzer",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showServerUrlDialog = false },
|
||||
title = { Text("Self-Hosted Server URL") },
|
||||
text = {
|
||||
Column {
|
||||
Button(
|
||||
onClick = { viewModel.sessionManager.clearSession() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Abmelden")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectView(
|
||||
uiState: AuthUiState,
|
||||
viewModel: AuthViewModel,
|
||||
focusManager: androidx.compose.ui.platform.FocusManager,
|
||||
) {
|
||||
Text(
|
||||
text = "Mit Server verbinden",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Gib die URL deines Mitbringsl-Servers ein. Danach wird geprüft, welche Anmeldemethoden verfügbar sind.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Error Message
|
||||
AnimatedVisibility(
|
||||
visible = uiState.connectError != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
uiState.connectError?.let { msg ->
|
||||
ErrorBox(msg)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.serverUrlInput,
|
||||
onValueChange = viewModel::onServerUrlChanged,
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://mitbringsl.example.com") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.connect()
|
||||
})
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.connect()
|
||||
},
|
||||
enabled = !uiState.isConnecting,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
if (uiState.isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
} else {
|
||||
Text("Verbinden", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginView(
|
||||
uiState: AuthUiState,
|
||||
viewModel: AuthViewModel,
|
||||
cfg: ServerAuthConfig,
|
||||
focusManager: androidx.compose.ui.platform.FocusManager,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} anmelden"
|
||||
uiState.isRegisteringMode -> "Konto erstellen"
|
||||
else -> "Anmelden"
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Error Message
|
||||
AnimatedVisibility(
|
||||
visible = uiState.errorMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
ErrorBox(msg)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isOidcMode) {
|
||||
// OIDC token input (App does PKCE itself; for now manual token paste).
|
||||
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(
|
||||
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(
|
||||
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
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.submit()
|
||||
},
|
||||
enabled = !uiState.isLoading,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = when {
|
||||
uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} verbinden"
|
||||
uiState.isRegisteringMode -> "Registrieren"
|
||||
else -> "Anmelden"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Toggle Buttons – only shown when the server offers multiple methods.
|
||||
if (cfg.hasMultipleMethods) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (cfg.passwordEnabled) {
|
||||
TextButton(onClick = viewModel::toggleMode) {
|
||||
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()
|
||||
text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (tempUrl.isNotBlank()) {
|
||||
viewModel.saveServerUrl(tempUrl)
|
||||
showServerUrlDialog = false
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text("Speichern")
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(0.dp))
|
||||
}
|
||||
|
||||
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 cfg.oidcDisplayName,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showServerUrlDialog = false }) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
}
|
||||
}
|
||||
} else if (cfg.passwordEnabled) {
|
||||
// Only password auth → just show register/login toggle.
|
||||
TextButton(onClick = viewModel::toggleMode) {
|
||||
Text(
|
||||
text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBox(msg: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = msg,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.example.mitbringsl.data.auth.AuthRepository
|
||||
import com.example.mitbringsl.data.auth.AuthResult
|
||||
import com.example.mitbringsl.data.auth.SessionManager
|
||||
import com.example.mitbringsl.data.remote.api.MitbringslApi
|
||||
import com.example.mitbringsl.data.remote.dto.AuthConfigDto
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -13,7 +15,40 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The auth methods the connected server offers. `null` until the user has
|
||||
* successfully connected to a server (or when running in local-only mode).
|
||||
*/
|
||||
data class ServerAuthConfig(
|
||||
val passwordEnabled: Boolean,
|
||||
val googleEnabled: Boolean,
|
||||
val googleDisplayName: String,
|
||||
val genericEnabled: Boolean,
|
||||
val genericDisplayName: String,
|
||||
) {
|
||||
/** At least one login method is available. */
|
||||
val hasAnyMethod: Boolean get() = passwordEnabled || googleEnabled || genericEnabled
|
||||
|
||||
/** Both password and an OIDC provider are available → show a mode toggle. */
|
||||
val hasMultipleMethods: Boolean get() {
|
||||
val oidcCount = listOf(googleEnabled, genericEnabled).count { it }
|
||||
return (if (passwordEnabled) 1 else 0) + oidcCount > 1
|
||||
}
|
||||
|
||||
val oidcDisplayName: String
|
||||
get() = when {
|
||||
genericEnabled -> genericDisplayName.ifBlank { "OIDC" }
|
||||
googleEnabled -> "Google"
|
||||
else -> "OIDC"
|
||||
}
|
||||
}
|
||||
|
||||
data class AuthUiState(
|
||||
val serverUrlInput: String = "",
|
||||
val isConnecting: Boolean = false,
|
||||
val serverConfig: ServerAuthConfig? = null,
|
||||
val connectError: String? = null,
|
||||
|
||||
val emailInput: String = "",
|
||||
val passwordInput: String = "",
|
||||
val displayNameInput: String = "",
|
||||
|
|
@ -22,12 +57,12 @@ data class AuthUiState(
|
|||
val isOidcMode: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val serverUrlInput: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val api: MitbringslApi,
|
||||
val sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -36,13 +71,87 @@ class AuthViewModel @Inject constructor(
|
|||
)
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
// --- server connection -------------------------------------------------
|
||||
|
||||
fun onServerUrlChanged(value: String) {
|
||||
_uiState.update { it.copy(serverUrlInput = value) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
serverUrlInput = value,
|
||||
connectError = null,
|
||||
serverConfig = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the server URL and fetches the server's auth config so the UI can
|
||||
* show only the login methods the server actually offers.
|
||||
*/
|
||||
fun connect() {
|
||||
val url = _uiState.value.serverUrlInput.trim()
|
||||
if (url.isBlank()) {
|
||||
_uiState.update { it.copy(connectError = "Bitte eine Server-URL eingeben.") }
|
||||
return
|
||||
}
|
||||
sessionManager.saveServerUrl(url)
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isConnecting = true, connectError = null) }
|
||||
try {
|
||||
val resp = api.getServerConfig()
|
||||
if (resp.isSuccessful && resp.body() != null) {
|
||||
val cfg = resp.body()!!.auth.toServerAuthConfig()
|
||||
if (!cfg.hasAnyMethod) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnecting = false,
|
||||
serverConfig = cfg,
|
||||
connectError = "Der Server bietet keine Anmeldemethode an.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Pick a sensible default mode based on what's available.
|
||||
val defaultOidc = !cfg.passwordEnabled && (cfg.googleEnabled || cfg.genericEnabled)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnecting = false,
|
||||
serverConfig = cfg,
|
||||
connectError = null,
|
||||
isOidcMode = defaultOidc,
|
||||
isRegisteringMode = false,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnecting = false,
|
||||
connectError = "Server nicht erreichbar (HTTP ${resp.code()}).",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnecting = false,
|
||||
connectError = e.localizedMessage ?: "Verbindung fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- form input --------------------------------------------------------
|
||||
|
||||
fun saveServerUrl(url: String) {
|
||||
sessionManager.saveServerUrl(url)
|
||||
_uiState.update { it.copy(serverUrlInput = sessionManager.getServerUrl(), errorMessage = null) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
serverUrlInput = sessionManager.getServerUrl(),
|
||||
serverConfig = null,
|
||||
connectError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEmailChanged(value: String) {
|
||||
|
|
@ -71,10 +180,15 @@ class AuthViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only callable when the server offers more than one method; toggles
|
||||
* between password and OIDC mode.
|
||||
*/
|
||||
fun toggleOidcMode() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isOidcMode = !it.isOidcMode,
|
||||
isRegisteringMode = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
|
@ -88,7 +202,8 @@ class AuthViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(errorMessage = "Bitte ID-Token eingeben.") }
|
||||
return
|
||||
}
|
||||
onOidcTokenReceived(provider = "generic", idToken = state.oidcTokenInput.trim())
|
||||
val provider = pickOidcProvider(state.serverConfig)
|
||||
onOidcTokenReceived(provider = provider, idToken = state.oidcTokenInput.trim())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -141,4 +256,22 @@ class AuthViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the OIDC provider key the backend expects. Prefers "generic" when
|
||||
* available (Authentik/Keycloak), falls back to "google".
|
||||
*/
|
||||
private fun pickOidcProvider(cfg: ServerAuthConfig?): String = when {
|
||||
cfg?.genericEnabled == true -> "generic"
|
||||
cfg?.googleEnabled == true -> "google"
|
||||
else -> "generic"
|
||||
}
|
||||
|
||||
private fun AuthConfigDto.toServerAuthConfig(): ServerAuthConfig = ServerAuthConfig(
|
||||
passwordEnabled = passwordEnabled,
|
||||
googleEnabled = oidc.google.enabled,
|
||||
googleDisplayName = oidc.google.displayName?.ifBlank { "Google" } ?: "Google",
|
||||
genericEnabled = oidc.generic.enabled,
|
||||
genericDisplayName = oidc.generic.displayName?.ifBlank { "OIDC" } ?: "OIDC",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue