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
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) {
@@ -19,11 +19,15 @@
<string name="dark_theme">Dark theme</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="lbl_username">Username</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>
@@ -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.
*
@@ -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}")
@@ -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 = {}
)
}
@@ -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) {