* [feature] issue #634: HTTPS + reverse-proxy server endpoint support - Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy) - AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs - ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://) - Data migration: ServerUrlMigration converts legacy host/port to baseUrl - UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning - Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev - Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API Co-Authored-By: M57 <Hy4ri@users.noreply.github.com> * fix(authlogin): basic-auth derives token automatically, no manual entry connectBasicAuth() built a fresh OkHttpClient for the password-login call and a different one for the ws-ticket call, so the Set-Cookie from /auth/password-login (the session token lives in the cookie, not the JSON body) never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null, and the UI fell back to asking the user to paste a token. Both calls now use the shared OkHttpProvider.probe client (same CookieManager jar), so the session cookie flows through and the WS ticket mints automatically — matching the dashboard contract (routes.py: password-login sets Set-Cookie, ws-ticket requires it). Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared client (and that ws-ticket 401s without it). Fixes the basic-auth token prompt reported against issue #634. * chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature) * ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs) * feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login - Replace 302-sniff probe with authoritative GET /api/status read (auth_required + auth_providers) to derive DashboardAuthMode. - Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639). - Render login UI by detected mode: token (loopback), username+password (basic/gated), or OAuth notice. - Remove legacy pairing-string box + onPairingString/applyLegacyPairing. - Extract internal deriveAuthMode() + add 5 unit tests. Closes nothing; OAuth tracked in #639. * fix(chat): don't block WS connect on empty token in gated (ticket) mode connectWebSocket() gated on getToken() != null, correct for loopback (token) mode but wrong for gated (ticket) mode: the ticket is minted fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted session cookie, so getToken() is expected empty at connect time. The early return left the WS unopened -> DISCONNECTED -> chat showed 'reconnect' immediately after basic-auth login. Only require a non-blank token in non-gated mode. Fixes #640. * test: remove onPairingString test cases for deleted pairing-login path The pairing-string login (onPairingString/applyLegacyPairing) was removed from ConnectViewModel, so the 5 tests that exercised it (3 in ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove them. Also drop the now-orphaned connect_pairing_string from values-ko. Fixes the Unit & Integration Tests CI failure on PR #638. --------- Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
d839c5e9ad
commit
26b104b2bb
40 files changed
+1299
-688
No files matched your search
@@ -227,10 +227,7 @@ internal fun InfoRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SectionCard(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
internal fun SectionCard(content: @Composable () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
@@ -240,15 +237,6 @@ internal fun SectionCard(
|
||||
elevation = CardDefaults.cardElevation(1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style =
|
||||
MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import com.m57.hermescontrol.ScreenRegistry
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.BottomNavDisplayMode
|
||||
@@ -22,8 +24,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class SettingsUiState(
|
||||
val host: String = "127.0.0.1",
|
||||
val port: String = "9119",
|
||||
val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
|
||||
val transportWarning: String? = null,
|
||||
val token: String = "",
|
||||
val autoReconnect: Boolean = true,
|
||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||
@@ -44,9 +46,9 @@ data class SettingsUiState(
|
||||
val showProfileDialog: Boolean = false,
|
||||
val editingProfileId: String? = null,
|
||||
val dialogProfileName: String = "",
|
||||
val dialogProfileHost: String = "",
|
||||
val dialogProfilePort: String = "",
|
||||
val dialogProfileBaseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
|
||||
val dialogProfileToken: String = "",
|
||||
val dialogProfileError: String? = null,
|
||||
// Delete confirmation
|
||||
val showDeleteConfirm: Boolean = false,
|
||||
val profileToDeleteId: String? = null,
|
||||
@@ -68,8 +70,7 @@ class SettingsViewModel(
|
||||
|
||||
private suspend fun loadSettings() {
|
||||
val selectedId = AuthManager.getSelectedProfileId()
|
||||
val host = AuthManager.getHost()
|
||||
val port = AuthManager.getPort().toString()
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
val token = AuthManager.getToken() ?: ""
|
||||
val autoReconnect = AuthManager.isAutoReconnect()
|
||||
val themePreference = AuthManager.getThemePreference()
|
||||
@@ -83,10 +84,14 @@ class SettingsViewModel(
|
||||
val appLanguage = AuthManager.getAppLanguage()
|
||||
val renameProfileName =
|
||||
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
|
||||
val transportWarning =
|
||||
runCatching {
|
||||
ServerEndpoint.parse(baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
|
||||
}.getOrNull()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = host,
|
||||
port = port,
|
||||
baseUrl = baseUrl,
|
||||
transportWarning = transportWarning,
|
||||
token = token,
|
||||
autoReconnect = autoReconnect,
|
||||
themePreference = themePreference,
|
||||
@@ -150,9 +155,9 @@ class SettingsViewModel(
|
||||
showProfileDialog = true,
|
||||
editingProfileId = null,
|
||||
dialogProfileName = "",
|
||||
dialogProfileHost = "127.0.0.1",
|
||||
dialogProfilePort = "9119",
|
||||
dialogProfileBaseUrl = ServerEndpoint.DEFAULT_BASE_URL,
|
||||
dialogProfileToken = "",
|
||||
dialogProfileError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -165,29 +170,28 @@ class SettingsViewModel(
|
||||
showProfileDialog = true,
|
||||
editingProfileId = profileId,
|
||||
dialogProfileName = profile.name,
|
||||
dialogProfileHost = profile.host,
|
||||
dialogProfilePort = profile.port.toString(),
|
||||
dialogProfileBaseUrl = profile.resolvedBaseUrl,
|
||||
dialogProfileToken = token,
|
||||
dialogProfileError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeProfileDialog() {
|
||||
_uiState.update {
|
||||
it.copy(showProfileDialog = false, editingProfileId = null)
|
||||
}
|
||||
_uiState.update { it.copy(showProfileDialog = false, editingProfileId = null) }
|
||||
}
|
||||
|
||||
fun onDialogProfileNameChange(value: String) {
|
||||
_uiState.update { it.copy(dialogProfileName = value) }
|
||||
}
|
||||
|
||||
fun onDialogProfileHostChange(value: String) {
|
||||
_uiState.update { it.copy(dialogProfileHost = value.trim()) }
|
||||
}
|
||||
|
||||
fun onDialogProfilePortChange(value: String) {
|
||||
_uiState.update { it.copy(dialogProfilePort = value.filter { c -> c.isDigit() }) }
|
||||
fun onDialogProfileBaseUrlChange(value: String) {
|
||||
val trimmed = value.trim()
|
||||
val error =
|
||||
runCatching {
|
||||
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING)
|
||||
}.exceptionOrNull()?.message
|
||||
_uiState.update { it.copy(dialogProfileBaseUrl = trimmed, dialogProfileError = error) }
|
||||
}
|
||||
|
||||
fun onDialogProfileTokenChange(value: String) {
|
||||
@@ -197,11 +201,14 @@ class SettingsViewModel(
|
||||
fun saveProfileFromDialog() {
|
||||
val state = _uiState.value
|
||||
val name = state.dialogProfileName.trim()
|
||||
val host = state.dialogProfileHost.trim()
|
||||
val port = state.dialogProfilePort.toIntOrNull() ?: return
|
||||
val token = state.dialogProfileToken
|
||||
val baseUrl = state.dialogProfileBaseUrl.trim()
|
||||
|
||||
if (name.isBlank() || host.isBlank()) return
|
||||
if (name.isBlank()) return
|
||||
val normalized =
|
||||
runCatching {
|
||||
ServerEndpoint.parse(baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).baseUrl.toString()
|
||||
}.getOrNull() ?: return
|
||||
val token = state.dialogProfileToken
|
||||
|
||||
val profiles = AuthManager.getConnectionProfiles().toMutableList()
|
||||
val editingId = state.editingProfileId
|
||||
@@ -211,7 +218,7 @@ class SettingsViewModel(
|
||||
val index = profiles.indexOfFirst { it.id == editingId }
|
||||
if (index == -1) return
|
||||
val oldToken = AuthManager.getProfileToken(editingId)
|
||||
profiles[index] = profiles[index].copy(name = name, host = host, port = port)
|
||||
profiles[index] = profiles[index].copy(name = name, baseUrl = normalized)
|
||||
AuthManager.saveConnectionProfiles(profiles)
|
||||
if (token != oldToken) {
|
||||
AuthManager.setProfileToken(editingId, token)
|
||||
@@ -221,8 +228,7 @@ class SettingsViewModel(
|
||||
val newProfile =
|
||||
ConnectionProfile(
|
||||
name = name,
|
||||
host = host,
|
||||
port = port,
|
||||
baseUrl = normalized,
|
||||
)
|
||||
profiles.add(newProfile)
|
||||
AuthManager.saveConnectionProfiles(profiles)
|
||||
@@ -267,12 +273,13 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun onHostChange(value: String) {
|
||||
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
|
||||
}
|
||||
|
||||
fun onPortChange(value: String) {
|
||||
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, isSaved = false) }
|
||||
fun onBaseUrlChange(value: String) {
|
||||
val trimmed = value.trim()
|
||||
val transportWarning =
|
||||
runCatching {
|
||||
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
|
||||
}.getOrNull()
|
||||
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = transportWarning, isSaved = false) }
|
||||
}
|
||||
|
||||
fun onTokenChange(value: String) {
|
||||
@@ -358,10 +365,12 @@ class SettingsViewModel(
|
||||
|
||||
fun save() {
|
||||
val state = _uiState.value
|
||||
val port = state.port.toIntOrNull() ?: 9119
|
||||
val normalized =
|
||||
runCatching {
|
||||
ServerEndpoint.parse(state.baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).baseUrl.toString()
|
||||
}.getOrNull() ?: ServerEndpoint.DEFAULT_BASE_URL
|
||||
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
AuthManager.setBaseUrl(normalized)
|
||||
AuthManager.setToken(state.token)
|
||||
AuthManager.setAutoReconnect(state.autoReconnect)
|
||||
// B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it
|
||||
|
||||
@@ -24,7 +24,7 @@ import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
|
||||
@Composable
|
||||
internal fun AboutSection(onLogout: () -> Unit) {
|
||||
SectionCard(title = stringResource(R.string.settings_sec_about)) {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_app_name),
|
||||
|
||||
@@ -57,7 +57,7 @@ internal fun AppearanceSection(
|
||||
appLanguage: String,
|
||||
onAppLanguageChange: (String) -> Unit,
|
||||
) {
|
||||
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
|
||||
SectionCard {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_item_theme),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -243,7 +243,7 @@ internal fun ChatSection(
|
||||
typingEffectDelayMs: Int,
|
||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
||||
) {
|
||||
SectionCard(title = stringResource(R.string.settings_sec_chat)) {
|
||||
SectionCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -326,7 +326,7 @@ internal fun NavBarSection(
|
||||
onRemoveNavItem: (String) -> Unit,
|
||||
onAddNavItem: (String) -> Unit,
|
||||
) {
|
||||
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
|
||||
SectionCard {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_item_bottom_nav_display_mode),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
@@ -22,7 +22,7 @@ internal fun BehaviorSection(
|
||||
autoReconnect: Boolean,
|
||||
onAutoReconnectChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
|
||||
SectionCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
||||
@@ -46,7 +46,7 @@ internal fun ConnectionSection(
|
||||
passwordVisible: Boolean,
|
||||
onPasswordVisibilityToggle: () -> Unit,
|
||||
) {
|
||||
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
|
||||
SectionCard {
|
||||
// ── Saved profiles list ──────────────────────────────────
|
||||
if (state.profiles.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -85,7 +85,7 @@ internal fun ConnectionSection(
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = "${profile.host}:${profile.port}",
|
||||
text = profile.resolvedBaseUrl,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -139,12 +139,11 @@ internal fun ConnectionSection(
|
||||
ProfileEditorDialog(
|
||||
isEditing = state.editingProfileId != null,
|
||||
name = state.dialogProfileName,
|
||||
host = state.dialogProfileHost,
|
||||
port = state.dialogProfilePort,
|
||||
baseUrl = state.dialogProfileBaseUrl,
|
||||
token = state.dialogProfileToken,
|
||||
error = state.dialogProfileError,
|
||||
onNameChange = viewModel::onDialogProfileNameChange,
|
||||
onHostChange = viewModel::onDialogProfileHostChange,
|
||||
onPortChange = viewModel::onDialogProfilePortChange,
|
||||
onBaseUrlChange = viewModel::onDialogProfileBaseUrlChange,
|
||||
onTokenChange = viewModel::onDialogProfileTokenChange,
|
||||
onSave = viewModel::saveProfileFromDialog,
|
||||
onDismiss = viewModel::closeProfileDialog,
|
||||
|
||||
+17
-16
@@ -25,12 +25,11 @@ import com.m57.hermescontrol.R
|
||||
internal fun ProfileEditorDialog(
|
||||
isEditing: Boolean,
|
||||
name: String,
|
||||
host: String,
|
||||
port: String,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
error: String?,
|
||||
onNameChange: (String) -> Unit,
|
||||
onHostChange: (String) -> Unit,
|
||||
onPortChange: (String) -> Unit,
|
||||
onBaseUrlChange: (String) -> Unit,
|
||||
onTokenChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -55,20 +54,22 @@ internal fun ProfileEditorDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = onHostChange,
|
||||
label = { Text(stringResource(R.string.settings_field_host)) },
|
||||
value = baseUrl,
|
||||
onValueChange = onBaseUrlChange,
|
||||
label = { Text(stringResource(R.string.connect_server_url_label)) },
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_server_url)) },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = onPortChange,
|
||||
label = { Text(stringResource(R.string.settings_field_port)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (isEditing) {
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
@@ -87,7 +88,7 @@ internal fun ProfileEditorDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onSave, enabled = name.isNotBlank() && host.isNotBlank()) {
|
||||
Button(onClick = onSave, enabled = name.isNotBlank() && error == null) {
|
||||
Text(stringResource(R.string.action_save))
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user