From c8aac2f6db6dce5fed1601a4daf1c029a40fb656 Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Thu, 30 Jul 2026 14:54:04 +0700 Subject: [PATCH] Implement sign in with api key --- .../torquevaultkmp/NetworkEngine.android.kt | 10 +- .../composeResources/values/strings.xml | 8 +- .../data/network/api/AuthApi.kt | 21 +++ .../screens/auth/AuthViewModel.kt | 22 ++- .../screens/auth/LoginScreen.kt | 169 ++++++++++++------ .../app/torquevaultkmp/NetworkEngine.ios.kt | 11 +- 6 files changed, 169 insertions(+), 72 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt index b82a5e5..a62de85 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt @@ -1,14 +1,12 @@ package com.digitoolsolutions.app.torquevaultkmp import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi -import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.Endpoints import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens @@ -17,9 +15,6 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.plugin -import io.ktor.client.request.header -import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json @@ -45,6 +40,9 @@ actual fun createPlatformHttpClient( } else null } refreshTokens { + if (response.call.request.url.encodedPath.contains(Endpoints.AUTH_TOKEN)) { + return@refreshTokens null + } Napier.d(">>>>> [NetworkEngine.android.kt] Access token expired, auto request refresh token...") val newAccess = authApi?.refreshToken() if (newAccess != null) { diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 977901c..de1d311 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -19,11 +19,15 @@ Dark theme Auto-connect device - Server URL + Server Address Ex: https://example.com Username Password - Login + Sign In + API Key + Use API key instead? + Use password instead? + Bonded Devices diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt index 8f2e3e4..91ba671 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt @@ -8,9 +8,12 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.contentType @@ -42,6 +45,24 @@ class AuthApi( false } } + + /** + * Attempts to authenticate the user using an API Key. + * Calls Endpoints.AUTH_REFRESH and stores the API Key as the access token. + */ + suspend fun loginWithApiKey(apiKey: String): Boolean { + return try { + val res: RefreshResponseDto = client.post("$baseUrl${Endpoints.AUTH_REFRESH}") { + contentType(ContentType.Application.Json) + setBody(RefreshRequestDto(apiKey)) + }.body() + tokenManager.saveTokens(res.access, apiKey) + true + } catch (e: Exception) { + Napier.d(">>>>> [AuthApi.kt] loginWithApiKey Exception: $e") + false + } + } /** * Attempts to refresh the access token using the stored refresh token. * diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt index 2741061..f6e1029 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt @@ -60,7 +60,27 @@ class AuthViewModel( if (success) { _loginState.value = LoginUiState.Success } else { - _loginState.value = LoginUiState.Error("Login failed: Invalid credentials") + _loginState.value = LoginUiState.Error("Incorrect username or password") + } + } catch (e: Exception) { + _loginState.value = LoginUiState.Error("Error: ${e.message}") + } + } + } + + /** + * Attempts to log in the user using an API Key. + */ + fun loginWithApiKey(apiKey: String) { + viewModelScope.launch { + _loginState.value = LoginUiState.Loading + tokenManager.saveServerUrl(_serverUrl.value) + try { + val success = authApi.loginWithApiKey(apiKey) + if (success) { + _loginState.value = LoginUiState.Success + } else { + _loginState.value = LoginUiState.Error("Invalid API Key") } } catch (e: Exception) { _loginState.value = LoginUiState.Error("Error: ${e.message}") diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt index 486d4f1..fd7261e 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -33,6 +34,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -53,7 +55,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination import kotlinx.coroutines.launch @@ -61,12 +62,15 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res +import torquevaultkmp.composeapp.generated.resources.api_key import torquevaultkmp.composeapp.generated.resources.btn_login import torquevaultkmp.composeapp.generated.resources.dts_text import torquevaultkmp.composeapp.generated.resources.lbl_password import torquevaultkmp.composeapp.generated.resources.lbl_server_url import torquevaultkmp.composeapp.generated.resources.lbl_username import torquevaultkmp.composeapp.generated.resources.server_url_placeholder +import torquevaultkmp.composeapp.generated.resources.use_api_key_instead +import torquevaultkmp.composeapp.generated.resources.use_password_instead /** * Screen for user authentication. @@ -80,12 +84,15 @@ fun LoginScreen( val serverUrl by viewModel.serverUrl.collectAsState() LoginScreenContent( - viewModel = viewModel, loginState = loginState, serverUrl = serverUrl, + onServerUrlChange = { viewModel.onChangeServerUrl(it) }, onLogin = { username, password -> viewModel.login(username, password) }, + onLoginWithApiKey = { apiKey -> + viewModel.loginWithApiKey(apiKey) + }, onLoginSuccess = { navController.navigate(HomeDestination.route) { popUpTo(AuthDestination.route) { inclusive = true } @@ -101,16 +108,19 @@ fun LoginScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreenContent( - viewModel: AuthViewModel, loginState: LoginUiState, serverUrl: String, + onServerUrlChange: (String) -> Unit, onLogin: (String, String) -> Unit, + onLoginWithApiKey: (String) -> Unit, onLoginSuccess: () -> Unit ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current var username by remember { mutableStateOf("admin") } var password by remember { mutableStateOf("admin") } + var apiKey by remember { mutableStateOf("") } + var useApiKey by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -165,70 +175,107 @@ fun LoginScreenContent( onNext = { focusManager.moveFocus(FocusDirection.Down) } ), value = serverUrl, - onValueChange = { viewModel.onChangeServerUrl(it) }, + onValueChange = { onServerUrlChange(it) }, label = { Text(stringResource(Res.string.lbl_server_url)) }, placeholder = { Text(stringResource(Res.string.server_url_placeholder)) } ) Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) } - ), - value = username, - onValueChange = { username = it }, - label = { Text(stringResource(Res.string.lbl_username)) } - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = - if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = image, - contentDescription = if (passwordVisible) "Hide password" else "Show password" - ) - } - }, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { - if (username.isBlank() || password.isBlank()) { + if (!useApiKey) { + /* Use username and password */ + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(Res.string.lbl_username)) } + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = image, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (username.isBlank() || password.isBlank()) { + scope.launch { + snackbarHostState.showSnackbar("Username and Password is required!") + } + } else { + onLogin(username, password) + keyboardController?.hide() + focusManager.clearFocus() + } + } + ), + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(Res.string.lbl_password)) } + ) + } else { + /* Use api key */ + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (apiKey.isBlank()) { + scope.launch { + snackbarHostState.showSnackbar("API Key is required!") + } + } else { + onLoginWithApiKey(apiKey) + keyboardController?.hide() + focusManager.clearFocus() + } + } + ), + value = apiKey, + onValueChange = { apiKey = it }, + label = { Text(stringResource(Res.string.api_key)) } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Button( + modifier = Modifier.fillMaxWidth().height(45.dp), + shape = RoundedCornerShape(8.dp), + onClick = { + if (useApiKey) { + if (apiKey.isBlank()) { scope.launch { - snackbarHostState.showSnackbar("Username and Password is required!") + snackbarHostState.showSnackbar("API Key is required!") } } else { - onLogin(username, password) + onLoginWithApiKey(apiKey.trim()) keyboardController?.hide() focusManager.clearFocus() } - } - ), - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(Res.string.lbl_password)) } - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - modifier = Modifier - .width(150.dp) - .height(45.dp), - onClick = { - if (username.isBlank() || password.isBlank()) { - scope.launch { - snackbarHostState.showSnackbar("Username and password is required!") - } } else { - onLogin(username.trim(), password.trim()) - keyboardController?.hide() - focusManager.clearFocus() + if (username.isBlank() || password.isBlank()) { + scope.launch { + snackbarHostState.showSnackbar("Username and password is required!") + } + } else { + onLogin(username.trim(), password.trim()) + keyboardController?.hide() + focusManager.clearFocus() + } } }, enabled = loginState !is LoginUiState.Loading @@ -243,6 +290,15 @@ fun LoginScreenContent( ) } } + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = { useApiKey = !useApiKey }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text(stringResource(if (useApiKey) Res.string.use_password_instead else Res.string.use_api_key_instead)) + } } } } @@ -251,10 +307,11 @@ fun LoginScreenContent( @Composable private fun LoginScreenPreview() { LoginScreenContent( - viewModel = viewModel(), loginState = LoginUiState.Idle, serverUrl = "http://localhost:3000", + onServerUrlChange = {}, onLogin = {_, _ -> }, + onLoginWithApiKey = {}, onLoginSuccess = {} ) } diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt index 6d9bdbc..61f69b0 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt @@ -1,14 +1,12 @@ package com.digitoolsolutions.app.torquevaultkmp import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi -import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.Endpoints import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.engine.darwin.Darwin -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens @@ -17,11 +15,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.plugin -import io.ktor.client.request.header -import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json actual fun createPlatformHttpClient( @@ -45,6 +39,9 @@ actual fun createPlatformHttpClient( } else null } refreshTokens { + if (response.call.request.url.encodedPath.contains(Endpoints.AUTH_TOKEN)) { + return@refreshTokens null + } Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...") val newAccess = authApi?.refreshToken() if (newAccess != null) {