Implemented ProtectAppSettingsDialog
This commit is contained in:
parent
f1c4c8ca13
commit
6e6449e956
|
@ -1,7 +1,6 @@
|
|||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
|
||||
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
|
||||
|
||||
plugins {
|
||||
|
@ -94,6 +93,8 @@ kotlin {
|
|||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(libs.sqldelight.android.driver)
|
||||
|
||||
implementation(libs.favre.bcrypt)
|
||||
}
|
||||
|
||||
nativeMain.dependencies {
|
||||
|
@ -110,6 +111,8 @@ kotlin {
|
|||
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
|
||||
implementation(libs.favre.bcrypt)
|
||||
|
||||
implementation(libs.logback)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SegmentedControlPreview() {
|
||||
SegmentedControl(
|
||||
options = listOf("Option 1", "Option 2", "Option 3"),
|
||||
selectedOption = "Option 1",
|
||||
onOptionSelected = { }
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SegmentedControlPreview_OnlyTwoOptions() {
|
||||
SegmentedControl(
|
||||
options = listOf("Option 1", "Option 2"),
|
||||
selectedOption = "Option 2",
|
||||
onOptionSelected = { }
|
||||
)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProtectAppSettingsDialogPreview() {
|
||||
val appSettings = AppSettings(AppAuthenticationMethod.Password)
|
||||
|
||||
ProtectAppSettingsDialog(appSettings) { }
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
|
||||
actual object PasswordService {
|
||||
|
||||
actual fun hashPassword(password: String): String =
|
||||
BCrypt.withDefaults().hashToString(12, password.toCharArray())
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
|
||||
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
|
||||
|
||||
}
|
|
@ -7,6 +7,7 @@ import androidx.compose.material.Text
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.SaveAs
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
@ -124,6 +125,15 @@ fun SideMenuContent() {
|
|||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationMenuItem(itemModifier, "Appzugang schützen", textColor, horizontalPadding = ItemHorizontalPadding,
|
||||
icon = { Icon(Icons.Outlined.Key, "Appzugang durch Passwort oder Biometrieeingabe schützen", Modifier.size(iconSize), tint = textColor) }) {
|
||||
uiState.showProtectAppSettingsScreen.value = true
|
||||
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,13 @@ private val formatUtil = DI.formatUtil
|
|||
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
|
||||
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
|
||||
val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState()
|
||||
|
||||
val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState()
|
||||
val showBankSettingsScreenForBank by uiState.showBankSettingsScreenForBank.collectAsState()
|
||||
val showBankAccountSettingsScreenForAccount by uiState.showBankAccountSettingsScreenForAccount.collectAsState()
|
||||
|
||||
val showExportScreen by uiState.showExportScreen.collectAsState()
|
||||
val showProtectAppSettingsScreen by uiState.showProtectAppSettingsScreen.collectAsState()
|
||||
|
||||
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
|
||||
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
|
||||
|
@ -35,6 +38,7 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
|
|||
TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null }
|
||||
}
|
||||
|
||||
|
||||
showAccountTransactionDetailsScreenForId?.let { transactionId ->
|
||||
DI.bankingService.getTransaction(transactionId)?.let { transaction ->
|
||||
AccountTransactionDetailsScreen(transaction) { uiState.showAccountTransactionDetailsScreenForId.value = null }
|
||||
|
@ -49,10 +53,15 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
|
|||
BankAccountSettingsScreen(account) { uiState.showBankAccountSettingsScreenForAccount.value = null }
|
||||
}
|
||||
|
||||
|
||||
if (showExportScreen) {
|
||||
ExportScreen { uiState.showExportScreen.value = false }
|
||||
}
|
||||
|
||||
if (showProtectAppSettingsScreen) {
|
||||
ProtectAppSettingsDialog(uiState.appSettings.value) { uiState.showProtectAppSettingsScreen.value = false }
|
||||
}
|
||||
|
||||
|
||||
tanChallengeReceived?.let { tanChallengeReceived ->
|
||||
EnterTanDialog(tanChallengeReceived) {
|
||||
|
|
|
@ -3,6 +3,7 @@ package net.codinux.banking.ui.config
|
|||
import net.codinux.banking.client.model.BankAccountType
|
||||
import net.codinux.banking.client.model.tan.ActionRequiringTan
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
|
||||
object Internationalization {
|
||||
|
||||
|
@ -44,4 +45,10 @@ object Internationalization {
|
|||
BankAccountType.Other -> "Sonstige"
|
||||
}
|
||||
|
||||
fun translate(authenticationMethod: AppAuthenticationMethod): String = when (authenticationMethod) {
|
||||
AppAuthenticationMethod.None -> "Ungeschützt"
|
||||
AppAuthenticationMethod.Password -> "Passwort"
|
||||
AppAuthenticationMethod.Biometric -> "Biometrie"
|
||||
}
|
||||
|
||||
}
|
|
@ -17,7 +17,7 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable // try BasicSecureTextField
|
||||
fun PasswordTextField(password: String = "", label: String = "Passwort", forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) {
|
||||
fun PasswordTextField(password: String = "", label: String = "Passwort", modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions? = null, forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) {
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
|
@ -29,7 +29,7 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
|
|||
value = password,
|
||||
onValueChange = { onChange(it) },
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val visibilityIcon = if (passwordVisible) {
|
||||
|
@ -43,7 +43,7 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
|
|||
modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible }
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
keyboardOptions = keyboardOptions?.copy(keyboardType = KeyboardType.Password) ?: KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
onEnterPressed = onEnterPressed
|
||||
)
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
|
||||
@Composable
|
||||
fun <T> SegmentedControl(
|
||||
options: Collection<T>,
|
||||
selectedOption: T,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Colors.Accent,
|
||||
cornerSize: Dp = 8.dp,
|
||||
getOptionDisplayText: ((T) -> String)? = null,
|
||||
onOptionSelected: (T) -> Unit
|
||||
) {
|
||||
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
Row(modifier.height(48.dp).border(2.dp, color, RoundedCornerShape(cornerSize))) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selectedOption
|
||||
val backgroundColor = if (isSelected) color else Color.Transparent
|
||||
val textColor = if (isSelected) Color.White else color
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable { onOptionSelected(option) }
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
.let {
|
||||
if (index == 0) {
|
||||
it.background(backgroundColor, RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize))
|
||||
} else if (index == options.size - 1) {
|
||||
it.background(backgroundColor, RoundedCornerShape(topEnd = cornerSize, bottomEnd = cornerSize))
|
||||
} else {
|
||||
it.background(backgroundColor)
|
||||
}
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getOptionDisplayText?.invoke(option) ?: option.toString(),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
if (index < options.size - 1) {
|
||||
Divider(Modifier.fillMaxHeight().width(1.dp), color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -23,6 +23,7 @@ fun FullscreenViewBase(
|
|||
title: String,
|
||||
confirmButtonTitle: String = "OK",
|
||||
confirmButtonEnabled: Boolean = true,
|
||||
showButtonBar: Boolean = true,
|
||||
onClosed: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
|
@ -46,19 +47,21 @@ fun FullscreenViewBase(
|
|||
content()
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
|
||||
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
|
||||
// }
|
||||
if (showButtonBar) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
|
||||
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
|
||||
// }
|
||||
//
|
||||
// Spacer(Modifier.width(8.dp))
|
||||
// Spacer(Modifier.width(8.dp))
|
||||
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = confirmButtonEnabled,
|
||||
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
|
||||
) {
|
||||
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = confirmButtonEnabled,
|
||||
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
|
||||
) {
|
||||
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.config.Internationalization
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.forms.PasswordTextField
|
||||
import net.codinux.banking.ui.forms.SegmentedControl
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.service.PasswordService
|
||||
|
||||
|
||||
private val buttonHeight = 50.dp
|
||||
|
||||
@Composable
|
||||
fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
|
||||
val currentAuthenticationMethod = appSettings.authenticationMethod
|
||||
|
||||
val isBiometricAuthenticationSupported = false
|
||||
|
||||
val supportedAuthenticationMethods = buildList {
|
||||
add(AppAuthenticationMethod.Password)
|
||||
if (isBiometricAuthenticationSupported) {
|
||||
add(AppAuthenticationMethod.Biometric)
|
||||
}
|
||||
add(AppAuthenticationMethod.None)
|
||||
}
|
||||
|
||||
|
||||
var selectedAuthenticationMethod by remember { mutableStateOf(if (appSettings.authenticationMethod == AppAuthenticationMethod.None) AppAuthenticationMethod.Password else appSettings.authenticationMethod) }
|
||||
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
|
||||
var confirmedNewPassword by remember { mutableStateOf("") }
|
||||
|
||||
val isRequiredDataEntered by remember(newPassword, confirmedNewPassword) {
|
||||
derivedStateOf { newPassword.isNotBlank() && newPassword == confirmedNewPassword }
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveAppSettings(appSettings: AppSettings) {
|
||||
coroutineScope.launch {
|
||||
DI.bankingService.saveAppSettings(appSettings)
|
||||
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppPasswordProtection() {
|
||||
appSettings.authenticationMethod = AppAuthenticationMethod.Password
|
||||
appSettings.hashedPassword = PasswordService.hashPassword(newPassword)
|
||||
|
||||
saveAppSettings(appSettings)
|
||||
}
|
||||
|
||||
fun removeAppProtection() {
|
||||
appSettings.authenticationMethod = AppAuthenticationMethod.None
|
||||
appSettings.hashedPassword = null
|
||||
|
||||
saveAppSettings(appSettings)
|
||||
}
|
||||
|
||||
|
||||
FullscreenViewBase("Appzugang schützen", showButtonBar = false, onClosed = onClosed) {
|
||||
Column(Modifier.fillMaxSize().padding(8.dp)) {
|
||||
|
||||
SegmentedControl(supportedAuthenticationMethods, selectedAuthenticationMethod, Modifier.padding(bottom = 20.dp), getOptionDisplayText = { Internationalization.translate(it) }) {
|
||||
selectedAuthenticationMethod = it
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f).verticalScroll()) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
if (currentAuthenticationMethod == AppAuthenticationMethod.None) {
|
||||
Text("Appzugangsschutz ist bereits ungeschützt", fontSize = 18.sp, textAlign = TextAlign.Center)
|
||||
} else {
|
||||
Text("Möchten Sie den Appzugangsschutz wirklich entfernen?", fontSize = 18.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) {
|
||||
PasswordTextField(newPassword, "Neues Password", keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)) { newPassword = it }
|
||||
|
||||
PasswordTextField(confirmedNewPassword, "Password bestätigen", Modifier.padding(top = 16.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)) { confirmedNewPassword = it }
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
|
||||
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = currentAuthenticationMethod != AppAuthenticationMethod.None,
|
||||
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { removeAppProtection() }) {
|
||||
Text("Appzugangsschutz entfernen", color = Color.White)
|
||||
}
|
||||
} else {
|
||||
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = isRequiredDataEntered,
|
||||
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { setAppPasswordProtection() }) {
|
||||
Text("Setzen", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
expect object PasswordService {
|
||||
|
||||
fun hashPassword(password: String): String
|
||||
|
||||
fun checkPassword(password: String, hashedPassword: String): Boolean
|
||||
|
||||
}
|
|
@ -63,6 +63,8 @@ class UiState : ViewModel() {
|
|||
|
||||
val showExportScreen = MutableStateFlow(false)
|
||||
|
||||
val showProtectAppSettingsScreen = MutableStateFlow(false)
|
||||
|
||||
|
||||
val tanChallengeReceived = MutableStateFlow<TanChallengeReceived?>(null)
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
|
||||
actual object PasswordService {
|
||||
|
||||
actual fun hashPassword(password: String): String =
|
||||
BCrypt.withDefaults().hashToString(12, password.toCharArray())
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
|
||||
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
actual object PasswordService {
|
||||
|
||||
// for iOS see e.g.
|
||||
// https://medium.com/@mohamed.ma872/strengthening-mobile-app-security-pbkdf2-bcrypt-and-scrypt-for-android-and-ios-8089b0edbf76
|
||||
// https://github.com/felipeflorencio/BCryptSwift
|
||||
|
||||
actual fun hashPassword(password: String): String {
|
||||
return password // TODO
|
||||
}
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
|
||||
return password == hashedPassword // TODO
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
actual object PasswordService {
|
||||
|
||||
actual fun hashPassword(password: String): String {
|
||||
return password // TODO
|
||||
}
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
|
||||
return password == hashedPassword // TODO
|
||||
}
|
||||
|
||||
}
|
|
@ -7,6 +7,8 @@ banking-client = "0.6.0"
|
|||
kcsv = "2.2.0"
|
||||
kotlinx-serializable = "1.7.1"
|
||||
|
||||
favre-bcrypt = "0.10.2"
|
||||
|
||||
klf = "1.6.1"
|
||||
logback = "1.5.7"
|
||||
|
||||
|
@ -36,6 +38,8 @@ kcsv = { group = "net.codinux.csv", name = "kcsv", version.ref = "kcsv" }
|
|||
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-serializable = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serializable" }
|
||||
|
||||
favre-bcrypt = { group = "at.favre.lib", name = "bcrypt", version.ref = "favre-bcrypt" }
|
||||
|
||||
klf = { group = "net.codinux.log", name = "klf", version.ref = "klf" }
|
||||
logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
|
||||
|
||||
|
|
Loading…
Reference in New Issue