diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 6090254d..cc2cf976 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -64,9 +63,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.ws.ConnectionStatus @@ -74,14 +73,15 @@ import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.theme.BottomNavDisplayMode import kotlinx.coroutines.launch import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent +import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent -import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent import com.m57.hermescontrol.ui.kanban.KanbanScreen as KanbanScreenContent import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent +import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent @@ -193,12 +193,25 @@ private fun appEntryProvider( sessionId: String?, openDrawer: () -> Unit, ) = entryProvider { - entry { - ConnectScreenContent( + entry { + LandingScreenContent( + onAuthLogin = { + NavigationController.backStack?.add(AuthLoginScreen) + }, + onPairingLogin = { + NavigationController.backStack?.add(PairingScreen) + }, + ) + } + + entry { + AuthLoginScreenContent( onConnected = { NavigationController.resetTo(ChatScreen) }, - modifier = Modifier.safeDrawingPadding(), + onBack = { + NavigationController.goBack() + }, ) } @@ -218,6 +231,14 @@ private fun appEntryProvider( entry { SettingsScreenContent( onBack = { NavigationController.goBack() }, + onLogout = { + // First clear auth state to trigger token flow + // then reset navigation to landing screen + NavigationController.backStack?.let { stack -> + stack.clear() + stack.add(LandingScreen) + } + }, ) } @@ -328,9 +349,17 @@ private fun appEntryProvider( fun MainNavigation(sessionId: String? = null) { val token by AuthManager.tokenFlow.collectAsState() val hasToken = !token.isNullOrBlank() - val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen + val startScreen: NavKey = if (hasToken) ChatScreen else LandingScreen - val backStack = rememberNavBackStack(startScreen) + val backStack = remember { NavBackStack(startScreen) } + LaunchedEffect(startScreen) { + // When start screen changes (e.g., logout → LandingScreen, or + // fresh login → ChatScreen), reset the backstack atomically. + if (backStack.lastOrNull() != startScreen) { + backStack.clear() + backStack.add(startScreen) + } + } NavigationController.backStack = backStack val currentScreen = backStack.lastOrNull() ?: startScreen @@ -353,7 +382,9 @@ fun MainNavigation(sessionId: String? = null) { NavigationController.updatePrimaryScreens(bottomNavKeys) } - val showBottomBar = currentScreen != ConnectScreen + val showBottomBar = + currentScreen != LandingScreen && + currentScreen != AuthLoginScreen val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS val openDrawer: () -> Unit = { scope.launch { drawerState.open() } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 4f18d40b..bacaf794 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -3,7 +3,9 @@ package com.m57.hermescontrol import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable -@Serializable data object ConnectScreen : NavKey +@Serializable data object LandingScreen : NavKey + +@Serializable data object AuthLoginScreen : NavKey @Serializable data object ChatScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 00fa0dbc..1382b01e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -30,6 +30,8 @@ object AuthManager { private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms" private const val KEY_CONNECTION_PROFILES = "connection_profiles" private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id" + private const val KEY_WS_AUTH_PARAM = "ws_auth_param" + private const val KEY_SESSION_COOKIE = "session_cookie" private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors" private const val KEY_THEME_PRESET = "theme_preset" private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode" @@ -93,9 +95,25 @@ object AuthManager { } private fun requirePrefs(): SharedPreferences = - prefs ?: throw IllegalStateException( - "AuthManager not initialised – call AuthManager.init(context) first", - ) + prefs ?: throw IllegalStateException("AuthManager not initialized. Call init(context) first.") + + fun setWsAuthParam(param: String) { + requirePrefs().edit().putString(KEY_WS_AUTH_PARAM, param).apply() + } + + // ── Session Cookie (for gated/dashboard REST API) ──────────────────── + + /** + * In gated mode (basic auth), the dashboard authenticates REST API + * requests via the `hermes_session_at` cookie, not via + * `Authorization: *** We store it here so [ApiClient]'s + * authInterceptor can add it as a `Cookie` header. + */ + fun getSessionCookie(): String? = requirePrefs().getString(KEY_SESSION_COOKIE, null) + + fun setSessionCookie(cookie: String?) { + requirePrefs().edit().putString(KEY_SESSION_COOKIE, cookie).apply() + } // ── Connection Profiles ────────────────────────────────────────────── @@ -290,7 +308,12 @@ object AuthManager { /** Convenience: build the WebSocket URL with token query param. * NOTE: Token in query string — trusted local network only. */ - fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}" + fun wsUrl(): String { + val raw = requirePrefs().getString(KEY_WS_AUTH_PARAM, "token") + val authParam = if (raw.isNullOrBlank()) "token" else raw + val credential = getToken().orEmpty() + return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential" + } // ── Bottom nav bar items ────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 3b7fb587..b926873f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -100,18 +100,30 @@ object ApiClient { val authInterceptor = Interceptor { chain -> - val token = AuthManager.getToken() - val request = - if (!token.isNullOrBlank()) { - chain - .request() + val request = chain.request() + val sessionCookie = AuthManager.getSessionCookie() + if (!sessionCookie.isNullOrBlank()) { + // Gated mode: authenticate via session cookie + chain.proceed( + request .newBuilder() - .addHeader("Authorization", "Bearer $token") - .build() + .addHeader("Cookie", "hermes_session_at=$sessionCookie") + .build(), + ) + } else { + // Loopback mode: authenticate via Bearer token + val token = AuthManager.getToken() + if (!token.isNullOrBlank()) { + chain.proceed( + request + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .build(), + ) } else { - chain.request() + chain.proceed(request) } - chain.proceed(request) + } } // SEC-11: No certificate pinning is configured by default since the app diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 2cf7403c..bad4c3e2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch import okhttp3.CertificatePinner import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -139,9 +140,51 @@ object HermesWsClient { intentionalClose.set(false) currentBackoff = INITIAL_BACKOFF_MS _connectionStatus.value = ConnectionStatus.CONNECTING + refreshWsTicketIfNeeded() openSocket() } + /** + * If a session cookie is present (gated mode), mint a fresh WS ticket + * from the dashboard. The ticket is single-use and has a 30-second TTL, + * so we must mint a new one on every connect (first launch and reconnect). + */ + private fun refreshWsTicketIfNeeded() { + val sessionCookie = AuthManager.getSessionCookie() + if (sessionCookie.isNullOrBlank()) { + // Loopback mode — the stored token IS a session token, not a + // WS ticket, so no refresh needed. + return + } + try { + val client = + OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + val request = + Request.Builder() + .url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket") + .header("Cookie", "hermes_session_at=$sessionCookie") + .post(RequestBody.create(null, "{}")) + .build() + val response = client.newCall(request).execute() + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + val ticketMatch = Regex(""""ticket":"([^"]+)"""").find(body) + val ticket = ticketMatch?.groupValues?.getOrNull(1) + if (!ticket.isNullOrBlank()) { + AuthManager.setToken(ticket) + if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") + } + } else { + Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}") + } + } catch (e: Exception) { + Log.w(TAG, "WS ticket refresh failed: ${e.message}") + } + } + /** Cleanly close the WebSocket and stop auto-reconnect. */ fun disconnect() { intentionalClose.set(true) @@ -197,6 +240,7 @@ object HermesWsClient { // ── Internal ───────────────────────────────────────────────────────── private fun openSocket() { + refreshWsTicketIfNeeded() val url = AuthManager.wsUrl() // B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token // as a query param — never stream to logcat in release builds. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt new file mode 100644 index 00000000..695bbe51 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt @@ -0,0 +1,279 @@ +package com.m57.hermescontrol.ui.authlogin + +import android.app.Application +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.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.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R + +@Composable +fun AuthLoginScreen( + onConnected: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AuthLoginViewModel = + viewModel( + factory = + run { + val app = LocalContext.current.applicationContext as Application + AuthLoginViewModelFactory(app) + }, + ), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.connectionSuccess) { + if (state.connectionSuccess) { + onConnected() + } + } + + var passwordVisible by remember { mutableStateOf(false) } + + Box( + modifier = + modifier + .fillMaxSize() + .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 icon + Box( + modifier = Modifier.size(64.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + } + + Text( + text = stringResource(R.string.auth_login_title), + style = + MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + ) + + Text( + text = stringResource(R.string.auth_login_desc), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Host field + OutlinedTextField( + value = state.host, + onValueChange = viewModel::onHostChange, + label = { Text(stringResource(R.string.auth_login_host_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + enabled = state.authMode == null, + ) + + // Port field + OutlinedTextField( + value = state.port, + onValueChange = viewModel::onPortChange, + label = { Text(stringResource(R.string.auth_login_port_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + enabled = state.authMode == null, + ) + + // Probe / probing indicator + if (state.probing) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.auth_login_probing_dashboard), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + + // ── Dynamic fields based on auth mode ── + + // Token field — shown for TOKEN_ONLY and ALL + AnimatedVisibility( + visible = + state.authMode == DashboardAuthMode.TOKEN_ONLY || + state.authMode == DashboardAuthMode.ALL, + enter = fadeIn(), + exit = fadeOut(), + ) { + OutlinedTextField( + value = state.token, + onValueChange = viewModel::onTokenChange, + label = { Text(stringResource(R.string.auth_login_token_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = null, + ) + } + }, + ) + } + + // Username field — shown for BASIC_AUTH and ALL + AnimatedVisibility( + visible = + state.authMode == DashboardAuthMode.BASIC_AUTH || + state.authMode == DashboardAuthMode.ALL, + enter = fadeIn(), + exit = fadeOut(), + ) { + OutlinedTextField( + value = state.username, + onValueChange = viewModel::onUsernameChange, + label = { Text(stringResource(R.string.auth_login_username_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Password field — shown for BASIC_AUTH and ALL + AnimatedVisibility( + visible = + state.authMode == DashboardAuthMode.BASIC_AUTH || + state.authMode == DashboardAuthMode.ALL, + enter = fadeIn(), + exit = fadeOut(), + ) { + OutlinedTextField( + value = state.password, + onValueChange = viewModel::onPasswordChange, + label = { Text(stringResource(R.string.auth_login_password_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + } + + // Error message + AnimatedVisibility( + visible = state.errorMessage != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + state.errorMessage?.let { msg -> + Text( + text = msg, + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.error, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Connect / Probe button + Button( + onClick = { + if (state.authMode == null) { + viewModel.probe() + } else { + viewModel.connect() + } + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + enabled = !state.isLoading && !state.probing, + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text( + text = + if (state.authMode == null) { + stringResource(R.string.auth_login_action_probing) + } else { + stringResource(R.string.auth_login_action_connect) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt new file mode 100644 index 00000000..da338816 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt @@ -0,0 +1,440 @@ +package com.m57.hermescontrol.ui.authlogin + +import android.app.Application +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.safeApiCall +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import java.util.concurrent.TimeUnit + +/** + * What auth the dashboard requires. + */ +enum class DashboardAuthMode { + /** Dashboard has no auth gate — just needs a session token. */ + TOKEN_ONLY, + + /** Dashboard has basic auth — needs username + password (and possibly a token). */ + BASIC_AUTH, + + /** Dashboard requires both basic auth credentials and a session token. */ + ALL, +} + +data class AuthLoginUiState( + val host: String = "127.0.0.1", + val port: String = "9119", + val token: String = "", + val username: String = "", + val password: String = "", + val isLoading: Boolean = false, + val probing: Boolean = false, + val authMode: DashboardAuthMode? = null, + val connectionSuccess: Boolean = false, + val errorMessage: String? = null, +) + +class AuthLoginViewModel(private val app: Application) : ViewModel() { + private val _uiState = MutableStateFlow(AuthLoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + companion object { + private const val TAG = "AuthLoginVM" + } + + private val probeClient: OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .followRedirects(false) + .build() + + fun onHostChange(value: String) { + _uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) } + } + + fun onPortChange(value: String) { + _uiState.update { + it.copy( + port = value.filter { c -> c.isDigit() }, + errorMessage = null, + authMode = null, + ) + } + } + + fun onTokenChange(value: String) { + _uiState.update { it.copy(token = value.trim(), errorMessage = null) } + } + + fun onUsernameChange(value: String) { + _uiState.update { it.copy(username = value.trim(), errorMessage = null) } + } + + fun onPasswordChange(value: String) { + _uiState.update { it.copy(password = value, errorMessage = null) } + } + + /** + * Step 1: Probe the dashboard to detect what auth it needs. + */ + fun probe() { + val state = _uiState.value + if (state.host.isBlank()) { + _uiState.update { it.copy(errorMessage = app.getString(R.string.auth_login_error_host_required)) } + return + } + val port = state.port.toIntOrNull() + if (port == null || port !in 1..65535) { + _uiState.update { it.copy(errorMessage = app.getString(R.string.auth_login_error_port_invalid)) } + return + } + + _uiState.update { it.copy(probing = true, errorMessage = null, authMode = null) } + + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + probeDashboardInternal(state.host, port) + } + _uiState.update { + it.copy( + probing = false, + authMode = result?.authMode, + token = result?.extractedToken ?: it.token, + errorMessage = + if (result == null) { + app.getString(R.string.auth_login_error_unreachable) + } else { + null + }, + ) + } + } + } + + /** + * Result of probing the dashboard. + */ + private data class ProbeResult( + val authMode: DashboardAuthMode, + val extractedToken: String? = null, + ) + + /** + * Probes the dashboard to determine [DashboardAuthMode]. + * Returns null if the dashboard is unreachable. + * When [ProbeResult.extractedToken] is non-null, the session token + * was found embedded in the dashboard SPA HTML and can be auto-populated. + */ + private fun probeDashboardInternal( + host: String, + port: Int, + ): ProbeResult? { + val baseUrl = "http://$host:$port" + + // Step 1: Check if dashboard is reachable via /api/status (always public) + val statusOk = + try { + val req = Request.Builder().url("$baseUrl/api/status").get().build() + val resp = probeClient.newCall(req).execute() + resp.isSuccessful + } catch (e: Exception) { + Log.w(TAG, "Status probe failed: ${e.message}") + return null // Dashboard unreachable + } + + if (!statusOk) return null + + // Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA + val needsBasicAuth = + try { + val req = Request.Builder().url(baseUrl).get().build() + val resp = probeClient.newCall(req).execute() + val code = resp.code + val location = resp.header("location", "") + // 302 to /login means basic auth is active + code == 302 && location?.contains("/login", ignoreCase = true) == true + } catch (e: Exception) { + Log.w(TAG, "SPA probe failed: ${e.message}") + false + } + + // Step 3: Extract token from SPA HTML (if reachable without redirect) + var extractedToken: String? = null + if (!needsBasicAuth) { + try { + val req = Request.Builder().url(baseUrl).get().build() + val resp = probeClient.newCall(req).execute() + val body = resp.body?.string() ?: "" + // Extract __HERMES_SESSION_TOKEN__ from the SPA HTML + val tokenMatch = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""").find(body) + extractedToken = tokenMatch?.groupValues?.getOrNull(1) + if (extractedToken == null) { + Log.w(TAG, "SPA has no __HERMES_SESSION_TOKEN__ — mode might require both auth") + } + } catch (e: Exception) { + Log.w(TAG, "SPA token extraction failed: ${e.message}") + } + } + + val authMode = + if (needsBasicAuth) { + DashboardAuthMode.BASIC_AUTH + } else if (extractedToken != null) { + DashboardAuthMode.TOKEN_ONLY + } else { + DashboardAuthMode.ALL + } + + return ProbeResult(authMode = authMode, extractedToken = extractedToken) + } + + /** + * Result of a successful connect attempt. + */ + private data class ConnectResult( + /** WS credential: session token (loopback) or WS ticket (gated). */ + val wsCredential: String, + /** Session cookie for REST API auth in gated mode (null in loopback). */ + val sessionCookie: String? = null, + ) + + /** + * Step 2: Connect using the detected auth mode. + */ + fun connect() { + val state = _uiState.value + val port = state.port.toIntOrNull() ?: return + + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + when (state.authMode) { + DashboardAuthMode.TOKEN_ONLY -> { + val token = connectTokenOnly(state.host, port, state.token) + if (token != null) ConnectResult(wsCredential = token) else null + } + DashboardAuthMode.BASIC_AUTH -> + connectBasicAuth(state.host, port, state.username, state.password) + DashboardAuthMode.ALL -> { + connectBasicAuth(state.host, port, state.username, state.password) + } + null -> null + } + } + + if (result != null) { + AuthManager.setHost(state.host) + AuthManager.setPort(port) + AuthManager.setToken(result.wsCredential) + if (result.sessionCookie != null) { + AuthManager.setSessionCookie(result.sessionCookie) + AuthManager.setWsAuthParam("ticket") + } else { + AuthManager.setSessionCookie(null) + AuthManager.setWsAuthParam("token") + } + ApiClient.rebuild() + _uiState.update { it.copy(isLoading = false, connectionSuccess = true) } + } + } + } + + /** + * Validate the token by calling /api/status with it. + */ + private suspend fun connectTokenOnly( + host: String, + port: Int, + token: String, + ): String? { + if (token.isBlank()) { + _uiState.update { + it.copy(isLoading = false, errorMessage = app.getString(R.string.auth_login_error_token_required)) + } + return null + } + + val tempApi = ApiClient.createTempService(host, port, token) + val result = safeApiCall { tempApi.getSessions() } + + return when (result) { + is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { + token + } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { + val msg = + when (val err = result.error) { + is com.m57.hermescontrol.data.remote.NetworkError.Http -> { + when (err.code) { + 401 -> app.getString(R.string.connect_error_401) + 403 -> app.getString(R.string.connect_error_403) + else -> app.getString(R.string.connect_error_http_code, err.code) + } + } + is com.m57.hermescontrol.data.remote.NetworkError.Connection -> { + app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "") + } + is com.m57.hermescontrol.data.remote.NetworkError.Unknown -> { + app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "") + } + } + _uiState.update { it.copy(isLoading = false, errorMessage = msg) } + null + } + } + } + + /** + * Authenticate with basic auth, then mint a WS ticket. + * Returns the WS ticket and the session cookie for REST auth. + */ + private fun connectBasicAuth( + host: String, + port: Int, + username: String, + password: String, + ): ConnectResult? { + if (username.isBlank()) { + _uiState.update { + it.copy(isLoading = false, errorMessage = app.getString(R.string.auth_login_error_username_required)) + } + return null + } + if (password.isBlank()) { + _uiState.update { + it.copy(isLoading = false, errorMessage = app.getString(R.string.auth_login_error_password_required)) + } + return null + } + + val baseUrl = "http://$host:$port" + val jsonBody = """{"provider":"basic","username":"$username","password":"$password","next":""}""" + + try { + // Step 1: Authenticate via the password login endpoint to get a session cookie + val loginClient = + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .followRedirects(false) + .build() + + val loginReq = + Request.Builder() + .url("$baseUrl/auth/password-login") + .header("Content-Type", "application/json") + .post(RequestBody.create(null, jsonBody)) + .build() + val loginResp = loginClient.newCall(loginReq).execute() + + if (!loginResp.isSuccessful) { + val msg = + when (loginResp.code) { + 401 -> app.getString(R.string.connect_error_401) + 403 -> app.getString(R.string.connect_error_403) + else -> app.getString(R.string.connect_error_http_code, loginResp.code) + } + _uiState.update { it.copy(isLoading = false, errorMessage = msg) } + return null + } + + // Step 2: Extract hermes_session_at cookie from Set-Cookie headers + val setCookieHeaders = loginResp.headers("Set-Cookie") + var sessionAccessToken: String? = null + for (header in setCookieHeaders) { + if (header.startsWith("hermes_session_at=")) { + sessionAccessToken = header.split(";")[0].removePrefix("hermes_session_at=").trim() + break + } + } + + if (sessionAccessToken.isNullOrBlank()) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Login succeeded but no session cookie received", + ) + } + return null + } + + val cookieHeader = "hermes_session_at=$sessionAccessToken" + + // Step 3: Mint a WebSocket ticket using the session cookie + val ticketClient = + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + val ticketReq = + Request.Builder() + .url("$baseUrl/api/auth/ws-ticket") + .header("Cookie", cookieHeader) + .post(RequestBody.create(null, "{}")) + .build() + val ticketResp = ticketClient.newCall(ticketReq).execute() + + if (!ticketResp.isSuccessful) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = app.getString(R.string.connect_error_http_code, ticketResp.code), + ) + } + return null + } + + val ticketBody = ticketResp.body?.string() ?: "" + val ticketMatch = Regex(""""ticket":"([^"]+)"""").find(ticketBody) + val ticket = ticketMatch?.groupValues?.getOrNull(1) + + if (ticket.isNullOrBlank()) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to obtain WebSocket ticket", + ) + } + return null + } + + return ConnectResult(wsCredential = ticket, sessionCookie = sessionAccessToken) + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = app.getString(R.string.connect_error_connection_failed, e.message ?: ""), + ) + } + return null + } + } +} + +class AuthLoginViewModelFactory( + private val app: Application, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return AuthLoginViewModel(app) as T + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt deleted file mode 100644 index 72adf64e..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ /dev/null @@ -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(), - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt new file mode 100644 index 00000000..7e154417 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt @@ -0,0 +1,113 @@ +package com.m57.hermescontrol.ui.landing + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +@Composable +fun LandingScreen( + onAuthLogin: () -> Unit, + onPairingLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Hero icon + 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), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.landing_title), + style = + MaterialTheme.typography.displayMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ), + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(R.string.landing_subtitle), + style = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onAuthLogin, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.landing_action_auth_login), + style = MaterialTheme.typography.titleMedium, + ) + } + + OutlinedButton( + onClick = onPairingLogin, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.landing_action_pairing_login), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index c3653be7..2f2ae760 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -81,6 +81,7 @@ enum class SettingsTab { @Composable fun SettingsScreen( onBack: () -> Unit, + onLogout: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, ) { @@ -810,6 +811,24 @@ fun SettingsScreen( color = MaterialTheme.colorScheme.primary, ), ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + viewModel.logout() + onLogout() + }, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_logout)) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index bdd8d433..2437ad90 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -153,6 +153,14 @@ class SettingsViewModel : ViewModel() { _uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) } } + /** Clear all auth credentials — logs out and returns to landing screen. */ + fun logout() { + AuthManager.setToken(null) + AuthManager.setSessionCookie(null) + AuthManager.setWsAuthParam("token") + // Don't rebuild ApiClient here — let the navigation complete first + } + /** All screens available for bottom-nav selection (name → display label). */ val availableNavItems: List> = listOf( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d45c676..31663472 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,4 +357,32 @@ Configured Keys: Configured Not set + + + Logout + + + Auth Login + Connect to your Hermes dashboard + Host + Port + Session Token + Username + Password + Connect + Probing dashboard… + Checking dashboard configuration… + Host is required + Invalid port (1–65535) + Session token is required + Username is required + Password is required + Dashboard unreachable — check IP and port + Connected! + + + Hermes Control + Control panel for Hermes Agent + Auth Login + Pairing Login \ No newline at end of file diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt index 96513f8b..c1bfa41d 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt @@ -50,6 +50,7 @@ class ApiClientTest { fun testAuthInterceptor_addsBearerTokenWhenPresent() = runTest { every { AuthManager.getToken() } returns "test-token-123" + every { AuthManager.getSessionCookie() } returns null // Rebuild to pick up the new mock values ApiClient.rebuild() @@ -71,6 +72,7 @@ class ApiClientTest { fun testAuthInterceptor_omitsBearerWhenTokenBlank() = runTest { every { AuthManager.getToken() } returns "" + every { AuthManager.getSessionCookie() } returns null ApiClient.rebuild() @@ -91,6 +93,7 @@ class ApiClientTest { fun testAuthInterceptor_omitsBearerWhenTokenNull() = runTest { every { AuthManager.getToken() } returns null + every { AuthManager.getSessionCookie() } returns null ApiClient.rebuild() @@ -111,6 +114,7 @@ class ApiClientTest { runTest { val rawToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-token" every { AuthManager.getToken() } returns rawToken + every { AuthManager.getSessionCookie() } returns null ApiClient.rebuild()