Implement sign in with api key

This commit is contained in:
2026-07-30 14:54:04 +07:00
parent c6cb6b0bcc
commit c8aac2f6db
6 changed files with 169 additions and 72 deletions
@@ -1,14 +1,12 @@
package com.digitoolsolutions.app.torquevaultkmp package com.digitoolsolutions.app.torquevaultkmp
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi 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.data.storage.TokenManager
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp 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.HttpTimeout
import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens 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.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging 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 io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -45,6 +40,9 @@ actual fun createPlatformHttpClient(
} else null } else null
} }
refreshTokens { 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...") Napier.d(">>>>> [NetworkEngine.android.kt] Access token expired, auto request refresh token...")
val newAccess = authApi?.refreshToken() val newAccess = authApi?.refreshToken()
if (newAccess != null) { if (newAccess != null) {
@@ -19,11 +19,15 @@
<string name="dark_theme">Dark theme</string> <string name="dark_theme">Dark theme</string>
<string name="auto_connect">Auto-connect device</string> <string name="auto_connect">Auto-connect device</string>
<string name="lbl_server_url">Server URL</string> <string name="lbl_server_url">Server Address</string>
<string name="server_url_placeholder">Ex: https://example.com</string> <string name="server_url_placeholder">Ex: https://example.com</string>
<string name="lbl_username">Username</string> <string name="lbl_username">Username</string>
<string name="lbl_password">Password</string> <string name="lbl_password">Password</string>
<string name="btn_login">Login</string> <string name="btn_login">Sign In</string>
<string name="api_key">API Key</string>
<string name="use_api_key_instead">Use API key instead?</string>
<string name="use_password_instead">Use password instead?</string>
<string name="bonded_title">Bonded Devices</string> <string name="bonded_title">Bonded Devices</string>
@@ -8,9 +8,12 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body 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.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType import io.ktor.http.contentType
@@ -42,6 +45,24 @@ class AuthApi(
false 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. * Attempts to refresh the access token using the stored refresh token.
* *
@@ -60,7 +60,27 @@ class AuthViewModel(
if (success) { if (success) {
_loginState.value = LoginUiState.Success _loginState.value = LoginUiState.Success
} else { } 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) { } catch (e: Exception) {
_loginState.value = LoginUiState.Error("Error: ${e.message}") _loginState.value = LoginUiState.Error("Error: ${e.message}")
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -33,6 +34,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -61,12 +62,15 @@ import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res 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.btn_login
import torquevaultkmp.composeapp.generated.resources.dts_text import torquevaultkmp.composeapp.generated.resources.dts_text
import torquevaultkmp.composeapp.generated.resources.lbl_password import torquevaultkmp.composeapp.generated.resources.lbl_password
import torquevaultkmp.composeapp.generated.resources.lbl_server_url import torquevaultkmp.composeapp.generated.resources.lbl_server_url
import torquevaultkmp.composeapp.generated.resources.lbl_username import torquevaultkmp.composeapp.generated.resources.lbl_username
import torquevaultkmp.composeapp.generated.resources.server_url_placeholder 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. * Screen for user authentication.
@@ -80,12 +84,15 @@ fun LoginScreen(
val serverUrl by viewModel.serverUrl.collectAsState() val serverUrl by viewModel.serverUrl.collectAsState()
LoginScreenContent( LoginScreenContent(
viewModel = viewModel,
loginState = loginState, loginState = loginState,
serverUrl = serverUrl, serverUrl = serverUrl,
onServerUrlChange = { viewModel.onChangeServerUrl(it) },
onLogin = { username, password -> onLogin = { username, password ->
viewModel.login(username, password) viewModel.login(username, password)
}, },
onLoginWithApiKey = { apiKey ->
viewModel.loginWithApiKey(apiKey)
},
onLoginSuccess = { onLoginSuccess = {
navController.navigate(HomeDestination.route) { navController.navigate(HomeDestination.route) {
popUpTo(AuthDestination.route) { inclusive = true } popUpTo(AuthDestination.route) { inclusive = true }
@@ -101,16 +108,19 @@ fun LoginScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreenContent( fun LoginScreenContent(
viewModel: AuthViewModel,
loginState: LoginUiState, loginState: LoginUiState,
serverUrl: String, serverUrl: String,
onServerUrlChange: (String) -> Unit,
onLogin: (String, String) -> Unit, onLogin: (String, String) -> Unit,
onLoginWithApiKey: (String) -> Unit,
onLoginSuccess: () -> Unit onLoginSuccess: () -> Unit
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
var username by remember { mutableStateOf("admin") } var username by remember { mutableStateOf("admin") }
var password 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) } var passwordVisible by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -165,12 +175,14 @@ fun LoginScreenContent(
onNext = { focusManager.moveFocus(FocusDirection.Down) } onNext = { focusManager.moveFocus(FocusDirection.Down) }
), ),
value = serverUrl, value = serverUrl,
onValueChange = { viewModel.onChangeServerUrl(it) }, onValueChange = { onServerUrlChange(it) },
label = { Text(stringResource(Res.string.lbl_server_url)) }, label = { Text(stringResource(Res.string.lbl_server_url)) },
placeholder = { Text(stringResource(Res.string.server_url_placeholder)) } placeholder = { Text(stringResource(Res.string.server_url_placeholder)) }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if (!useApiKey) {
/* Use username and password */
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -215,12 +227,46 @@ fun LoginScreenContent(
onValueChange = { password = it }, onValueChange = { password = it },
label = { Text(stringResource(Res.string.lbl_password)) } 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)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(45.dp),
.width(150.dp) shape = RoundedCornerShape(8.dp),
.height(45.dp),
onClick = { onClick = {
if (useApiKey) {
if (apiKey.isBlank()) {
scope.launch {
snackbarHostState.showSnackbar("API Key is required!")
}
} else {
onLoginWithApiKey(apiKey.trim())
keyboardController?.hide()
focusManager.clearFocus()
}
} else {
if (username.isBlank() || password.isBlank()) { if (username.isBlank() || password.isBlank()) {
scope.launch { scope.launch {
snackbarHostState.showSnackbar("Username and password is required!") snackbarHostState.showSnackbar("Username and password is required!")
@@ -230,6 +276,7 @@ fun LoginScreenContent(
keyboardController?.hide() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus()
} }
}
}, },
enabled = loginState !is LoginUiState.Loading 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 @Composable
private fun LoginScreenPreview() { private fun LoginScreenPreview() {
LoginScreenContent( LoginScreenContent(
viewModel = viewModel(),
loginState = LoginUiState.Idle, loginState = LoginUiState.Idle,
serverUrl = "http://localhost:3000", serverUrl = "http://localhost:3000",
onServerUrlChange = {},
onLogin = {_, _ -> }, onLogin = {_, _ -> },
onLoginWithApiKey = {},
onLoginSuccess = {} onLoginSuccess = {}
) )
} }
@@ -1,14 +1,12 @@
package com.digitoolsolutions.app.torquevaultkmp package com.digitoolsolutions.app.torquevaultkmp
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi 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.data.storage.TokenManager
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin 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.HttpTimeout
import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens 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.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging 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 io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
actual fun createPlatformHttpClient( actual fun createPlatformHttpClient(
@@ -45,6 +39,9 @@ actual fun createPlatformHttpClient(
} else null } else null
} }
refreshTokens { 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...") Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...")
val newAccess = authApi?.refreshToken() val newAccess = authApi?.refreshToken()
if (newAccess != null) { if (newAccess != null) {