feat(#349): Smart auth login flow — probe dashboard and show correct fields (#351)

* feat(#349): smart auth login flow — probe dashboard, show correct fields

Landing screen with two buttons (Auth Login / Pairing Login) replaces
ConnectScreen as the start screen. Auth login probes the dashboard to
detect what auth it needs (token-only, basic-auth, or both) and shows
only the relevant input fields.

Changes:
- NavigationKeys.kt: add LandingScreen, AuthLoginScreen NavKeys
- LandingScreen.kt: new screen with hero icon + two login buttons
- AuthLoginScreen.kt: IP/Port input, probe logic, dynamic field rendering
- AuthLoginViewModel.kt: probe API, dashboard auth detection, connect flow
- Navigation.kt: wire new screens, start on LandingScreen, hide bottom bar
- strings.xml: add string resources for both screens

* fix(#351): align AuthLoginScreen field names with ViewModel API, fix TAG refs

- state.isProbing → state.probing
- DashboardAuthMode.BOTH → DashboardAuthMode.ALL
- viewModel.probe() → viewModel.probeDashboard()
- state.isConnecting → state.isLoading
- Removed unused TAG constant, replaced with inline tag string
- Made connectTokenOnly suspend to fix safeApiCall compilation

This fixes the CI compilation errors.

* fix(#351): revert probeDashboard() call to probe() to match VM

* fix(#351): rename isProbing to probing in ViewModel

* fix(#351): follow redirects in basic auth login — / redirects to /login

* fix(#351): show all fields when basic auth detected — still needs session token for WS

* refactor(#349): remove old ConnectScreen, clean up NavKey + navigation

* fix(#351): validate token against /api/sessions (auth-protected) instead of /api/status (public)

/api/status is a public endpoint that returns 200 even with wrong tokens,
causing invalid tokens to be saved and the WebSocket to spin forever on
the chat screen. /api/sessions requires auth and returns 401 on bad tokens.

* fix(#351): rewrite basic auth login — POST to /auth/password-login, then fetch SPA with cookie to extract token

* fix(#351): use ticket-based WS auth when basic auth is active

The updated dashboard on 0.0.0.0 always has auth_required=True, which
rejects legacy ?token= in favor of ?ticket= (single-use, 30s TTL).
Added AuthManager.setWsAuthParam() to support both ?token= (loopback)
and ?ticket= (gated mode) in wsUrl(). connectBasicAuth now mints a
ticket via POST /api/auth/ws-ticket after login instead of extracting
the token from the SPA.

* fix(#351): use requirePrefs() instead of direct prefs reference in wsUrl()

* fix(#351): handle relaxed-mock empty string in wsUrl() authParam fallback

* feat(#349): auto-extract session token from SPA HTML during probe

The probe now extracts the __HERMES_SESSION_TOKEN__ value from the
dashboard SPA and auto-populates the token field in TOKEN_ONLY mode,
eliminating the need for manual token pasting.

- Introduced ProbeResult data class to carry authMode + extractedToken
- Regex extraction of __HERMES_SESSION_TOKEN__ from dashboard HTML
- Token field auto-filled on probe completion for TOKEN_ONLY mode

* fix(#349): return BASIC_AUTH from probe when basic auth detected

Bug: probe returned ALL instead of BASIC_AUTH for basic-auth-only
dashboards, causing token+credential fields to show and the connect
flow to fall through to connectTokenOnly() on basic auth failure,
producing a confusing 'Unauthorized (HTTP 401)' NetworkResult error.

Fix: probe now returns DashboardAuthMode.BASIC_AUTH when a redirect
to /login is detected. BASIC_AUTH in connect() calls
connectBasicAuth() directly with no fallback. ALL mode also no longer
falls through to connectTokenOnly().

* fix(#349): use session cookie for REST API in gated mode

The dashboard's gated mode authenticates REST API requests via
session cookies (hermes_session_at), not Bearer tokens. The app
was storing the WS ticket as the token and sending it as
Authorization: Bearer <ticket> for all REST calls, which the
server rejected with 401.

- Extract hermes_session_at cookie from Set-Cookie header after
  POST /auth/password-login and store it in AuthManager
- ApiClient authInterceptor now sends Cookie header when session
  cookie is present, falls back to Bearer token for loopback mode
- ConnectResult carries both WS credential (ticket) and session
  cookie separately
- WS ticket used only for WebSocket (?ticket=), session cookie
  used for REST API

* feat(#349): add logout button to Settings > About

Red error-style button that clears all auth credentials (token,
session cookie, WS auth param) and navigates back to LandingScreen.
Wired through SettingsScreen onLogout callback to Navigation.kt.

* fix(#349): improve logout navigation flow

* fix(#349): keep backstack stable across logout

Use remember without keys for NavBackStack so the same backstack
object persists across startScreen changes. Use LaunchedEffect keyed
on startScreen to reset stack content atomically. This avoids
NavDisplay losing snapshot observation when a new NavBackStack
object is created.

* fix(#349): mint fresh WS ticket on every connect

The WS ticket is a single-use 30-second credential. On app restart
(or reconnect), the stored ticket is expired. Now refreshWsTicketIfNeeded()
mints a new ticket using the session cookie before every WS connection
attempt. Loopback mode (no session cookie) is unaffected.

* fix(#349): refresh ticket in openSocket for reconnect paths

scheduleReconnect() bypasses connect() and calls openSocket()
directly. Moving the ticket refresh into openSocket() ensures
every WS connection attempt — first launch and reconnect —
uses a fresh ticket.

* fix(#349): stub getSessionCookie() in ApiClientTest

mockkObject(AuthManager) is strict, so unstubbed getSessionCookie()
calls throw MockKException which manifests as IOException.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-24 16:37:09 +03:00
1 parent 11ca9b55c8
commit c1af989220
13 files changed
+1026 -362

No files matched your search

@@ -1,339 +0,0 @@
package com.m57.hermescontrol.ui.connect
import android.app.Application
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
@Composable
fun ConnectScreen(
onConnected: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ConnectViewModel =
viewModel(
factory =
run {
val app = LocalContext.current.applicationContext as Application
ConnectViewModelFactory(app)
},
),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.connectionSuccess) {
if (state.connectionSuccess) {
onConnected()
}
}
var passwordVisible by remember { mutableStateOf(false) }
var showManualFields by remember { mutableStateOf(false) }
Box(
modifier =
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.imePadding(),
contentAlignment = Alignment.Center,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Hero brand
Box(
modifier =
Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(44.dp),
)
}
Text(
text = stringResource(R.string.notif_title),
style =
MaterialTheme.typography.displayMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
),
)
Text(
text = stringResource(R.string.nav_drawer_subtitle),
style =
MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(24.dp))
// Connection Profiles Dropdown / Selection
if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { profilesExpanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(
text =
state.selectedProfile?.let { p ->
stringResource(R.string.connect_selected_profile, p.name)
} ?: stringResource(R.string.connect_action_select_profile),
)
}
DropdownMenu(
expanded = profilesExpanded,
onDismissRequest = { profilesExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.connect_profile_none)) },
onClick = {
viewModel.selectProfile(null)
profilesExpanded = false
},
)
state.profiles.forEach { profile ->
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(profile)
profilesExpanded = false
},
)
}
}
}
}
// Pairing (primary path)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.connect_pairing_string),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
)
var pairingString by remember { mutableStateOf("") }
OutlinedTextField(
value = pairingString,
onValueChange = {
pairingString = it
if (it.isNotBlank()) viewModel.onPairingString(it)
},
placeholder = { Text(stringResource(R.string.connect_placeholder_pairing)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.errorMessage?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
}
}
// Profile Save Options prior to connecting
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = state.saveProfile,
onCheckedChange = viewModel::onSaveProfileChange,
)
Text(
text = stringResource(R.string.connect_save_profile),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
AnimatedVisibility(visible = state.saveProfile) {
OutlinedTextField(
value = state.profileName,
onValueChange = viewModel::onProfileNameChange,
label = { Text(stringResource(R.string.connect_profile_name)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_profile_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
Button(
onClick = viewModel::connect,
enabled = !state.isConnecting,
modifier =
Modifier
.fillMaxWidth()
.height(52.dp),
) {
if (state.isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.action_connect), style = MaterialTheme.typography.labelLarge)
}
}
TextButton(
onClick = { showManualFields = !showManualFields },
modifier = Modifier.fillMaxWidth(),
) {
Text(
text =
if (showManualFields) {
stringResource(R.string.connect_action_hide_manual)
} else {
stringResource(R.string.connect_action_show_manual)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
AnimatedVisibility(visible = showManualFields) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider()
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_token)) },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
stringResource(R.string.content_desc_hide_token)
} else {
stringResource(R.string.content_desc_show_token)
},
)
}
},
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}