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) {