diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 58645a89..bb96db02 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget object Config { - const val migrationCode = 5 + const val migrationCode = 6 // Suwayomi-Server version const val tachideskVersion = "v2.1.1959" diff --git a/data/src/commonMain/graphql/User.graphql b/data/src/commonMain/graphql/User.graphql new file mode 100644 index 00000000..a43745be --- /dev/null +++ b/data/src/commonMain/graphql/User.graphql @@ -0,0 +1,12 @@ +mutation Login( $username: String!, $password: String!) { + login(input: {username: $username, password: $password}) { + refreshToken + accessToken + } +} + +mutation RefreshAccessToken($refreshToken: String!) { + refreshToken(input: {refreshToken: $refreshToken}) { + accessToken + } +} diff --git a/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt b/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt index 3127007f..fc690166 100644 --- a/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt +++ b/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt @@ -18,6 +18,7 @@ import ca.gosyer.jui.data.manga.MangaRepositoryImpl import ca.gosyer.jui.data.settings.SettingsRepositoryImpl import ca.gosyer.jui.data.source.SourceRepositoryImpl import ca.gosyer.jui.data.updates.UpdatesRepositoryImpl +import ca.gosyer.jui.data.user.UserRepositoryImpl import ca.gosyer.jui.domain.backup.service.BackupRepository import ca.gosyer.jui.domain.category.service.CategoryRepository import ca.gosyer.jui.domain.chapter.service.ChapterRepository @@ -27,12 +28,13 @@ import ca.gosyer.jui.domain.global.service.GlobalRepository import ca.gosyer.jui.domain.library.service.LibraryRepository import ca.gosyer.jui.domain.manga.service.MangaRepository import ca.gosyer.jui.domain.server.Http +import ca.gosyer.jui.domain.server.HttpNoAuth import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.settings.service.SettingsRepository import ca.gosyer.jui.domain.source.service.SourceRepository import ca.gosyer.jui.domain.updates.service.UpdatesRepository +import ca.gosyer.jui.domain.user.service.UserRepository import com.apollographql.apollo.ApolloClient -import com.apollographql.apollo.annotations.ApolloExperimental import com.apollographql.apollo.network.ws.GraphQLWsProtocol import com.apollographql.ktor.ktorClient import io.ktor.client.HttpClient @@ -50,6 +52,8 @@ import me.tatarka.inject.annotations.Provides typealias ApolloAppClient = StateFlow +typealias ApolloAppClientNoAuth = StateFlow + private fun getApolloClient( httpClient: HttpClient, serverUrl: Url, @@ -66,7 +70,7 @@ private fun getApolloClient( } interface DataComponent : SharedDataComponent { - @OptIn(ApolloExperimental::class) + @Provides @AppScope fun apolloAppClient( @@ -81,6 +85,20 @@ interface DataComponent : SharedDataComponent { getApolloClient(http.value, serverPreferences.serverUrl().get()), ) + @Provides + @AppScope + fun apolloAppClientNoAuth( + httpNoAuth: HttpNoAuth, + serverPreferences: ServerPreferences, + ): ApolloAppClientNoAuth = + httpNoAuth + .map { getApolloClient(it, serverPreferences.serverUrl().get()) } + .stateIn( + GlobalScope, + SharingStarted.Eagerly, + getApolloClient(httpNoAuth.value, serverPreferences.serverUrl().get()), + ) + @Provides fun settingsRepository(apolloAppClient: ApolloAppClient): SettingsRepository = SettingsRepositoryImpl(apolloAppClient) @@ -147,6 +165,15 @@ interface DataComponent : SharedDataComponent { serverPreferences: ServerPreferences, ): UpdatesRepository = UpdatesRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get()) + @Provides + fun userRepository( + apolloAppClient: ApolloAppClient, + apolloAppClientNoAuth: ApolloAppClientNoAuth, + http: Http, + httpNoAuth: HttpNoAuth, + serverPreferences: ServerPreferences, + ): UserRepository = UserRepositoryImpl(apolloAppClient, apolloAppClientNoAuth, http, httpNoAuth,serverPreferences.serverUrl().get()) + @Provides fun backupRepository( apolloAppClient: ApolloAppClient, diff --git a/data/src/commonMain/kotlin/ca/gosyer/jui/data/user/UserRepositoryImpl.kt b/data/src/commonMain/kotlin/ca/gosyer/jui/data/user/UserRepositoryImpl.kt new file mode 100644 index 00000000..88c817bb --- /dev/null +++ b/data/src/commonMain/kotlin/ca/gosyer/jui/data/user/UserRepositoryImpl.kt @@ -0,0 +1,76 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.data.user + +import ca.gosyer.jui.data.ApolloAppClient +import ca.gosyer.jui.data.ApolloAppClientNoAuth +import ca.gosyer.jui.data.graphql.LoginMutation +import ca.gosyer.jui.data.graphql.RefreshAccessTokenMutation +import ca.gosyer.jui.domain.server.Http +import ca.gosyer.jui.domain.server.HttpNoAuth +import ca.gosyer.jui.domain.user.model.LoginData +import ca.gosyer.jui.domain.user.service.UserRepository +import com.apollographql.apollo.ApolloClient +import io.ktor.client.request.forms.formData +import io.ktor.client.request.post +import io.ktor.http.Url +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +class UserRepositoryImpl( + private val apolloAppClient: ApolloAppClient, + private val apolloAppClientNoAuth: ApolloAppClientNoAuth, + private val http: Http, + private val httpNoAuth: HttpNoAuth, + private val serverUrl: Url, +) : UserRepository { + val apolloClient: ApolloClient + get() = apolloAppClient.value + + val apolloClientNoAuth: ApolloClient + get() = apolloAppClientNoAuth.value + + override fun loginUI(username: String, password: String): Flow { + return apolloClientNoAuth.mutation( + LoginMutation(username, password), + ) + .toFlow() + .map { + val data = it.dataAssertNoErrors.login + LoginData(data.refreshToken, data.accessToken) + } + } + + override fun refreshUI(refreshToken: String): Flow { + return apolloClientNoAuth.mutation( + RefreshAccessTokenMutation(refreshToken), + ) + .toFlow() + .map { + val data = it.dataAssertNoErrors.refreshToken + data.accessToken + } + } + + override fun loginSimple(username: String, password: String): Flow { + return flow { + val cookie = httpNoAuth.value.post( + "/login.html", + ) { + formData { + append("user", username) + append("pass", password) + } + }.headers["Set-Cookie"] + + cookie ?: throw NullPointerException("Missing Set-Cookie header") + + emit(cookie.substringAfter("JSESSIONID=").substringBefore(";")) + } + } +} diff --git a/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppComponent.kt b/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppComponent.kt index 4e058aea..ab5334f9 100644 --- a/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppComponent.kt +++ b/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppComponent.kt @@ -29,7 +29,7 @@ abstract class AppComponent( @get:AppScope @get:Provides protected val appMigrationsFactory: AppMigrations - get() = AppMigrations(migrationPreferences, contextWrapper) + get() = AppMigrations(migrationPreferences, serverHostPreferences, contextWrapper) val bind: ViewModelComponent @Provides get() = this diff --git a/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppMigrations.kt b/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppMigrations.kt index d3e829be..85ca56d3 100644 --- a/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppMigrations.kt +++ b/desktop/src/main/kotlin/ca/gosyer/jui/desktop/AppMigrations.kt @@ -9,6 +9,8 @@ package ca.gosyer.jui.desktop import ca.gosyer.appdirs.AppDirs import ca.gosyer.jui.desktop.build.BuildConfig import ca.gosyer.jui.domain.migration.service.MigrationPreferences +import ca.gosyer.jui.domain.server.service.ServerHostPreferences +import ca.gosyer.jui.domain.settings.model.AuthMode import ca.gosyer.jui.uicore.vm.ContextWrapper import com.diamondedge.logging.logging import me.tatarka.inject.annotations.Inject @@ -18,6 +20,7 @@ import okio.Path.Companion.toPath @Inject class AppMigrations( private val migrationPreferences: MigrationPreferences, + private val serverHostPreference: ServerHostPreferences, private val contextWrapper: ContextWrapper, ) { @Suppress("KotlinConstantConditions") @@ -32,8 +35,8 @@ class AppMigrations( } if (oldVersion < 5) { - val oldDir = AppDirs("Tachidesk-JUI").getUserDataDir().toPath() - val newDir = AppDirs("Suwayomi-JUI").getUserDataDir().toPath() + val oldDir = AppDirs { appName = "Tachidesk-JUI" }.getUserDataDir().toPath() + val newDir = AppDirs { appName = "Suwayomi-JUI" }.getUserDataDir().toPath() try { FileSystem.SYSTEM.list(oldDir) .filter { FileSystem.SYSTEM.metadata(it).isDirectory } @@ -48,6 +51,14 @@ class AppMigrations( log.e(e) { "Failed to run directory migration" } } } + if (oldVersion < 6) { + val basicAuthEnabled = serverHostPreference.basicAuthEnabled().get() + if (basicAuthEnabled) { + serverHostPreference.authMode().set(AuthMode.BASIC_AUTH) + } + serverHostPreference.authUsername().set(serverHostPreference.basicAuthUsername().get()) + serverHostPreference.authPassword().set(serverHostPreference.basicAuthPassword().get()) + } return true } diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/SharedDomainComponent.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/SharedDomainComponent.kt index f369f2be..b821abe6 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/SharedDomainComponent.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/SharedDomainComponent.kt @@ -16,13 +16,17 @@ import ca.gosyer.jui.domain.migration.interactor.RunMigrations import ca.gosyer.jui.domain.migration.service.MigrationPreferences import ca.gosyer.jui.domain.reader.service.ReaderPreferences import ca.gosyer.jui.domain.server.Http +import ca.gosyer.jui.domain.server.HttpNoAuth import ca.gosyer.jui.domain.server.httpClient +import ca.gosyer.jui.domain.server.httpClientNoAuth import ca.gosyer.jui.domain.server.service.ServerHostPreferences import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.source.service.CatalogPreferences import ca.gosyer.jui.domain.ui.service.UiPreferences import ca.gosyer.jui.domain.updates.interactor.UpdateChecker import ca.gosyer.jui.domain.updates.service.UpdatePreferences +import ca.gosyer.jui.domain.user.interactor.UserRefreshUI +import ca.gosyer.jui.domain.user.service.UserPreferences import kotlinx.serialization.json.Json import me.tatarka.inject.annotations.Provides @@ -39,6 +43,8 @@ interface SharedDomainComponent : CoreComponent { val http: Http + val httpNoAuth: HttpNoAuth + val serverPreferences: ServerPreferences val extensionPreferences: ExtensionPreferences @@ -59,12 +65,30 @@ interface SharedDomainComponent : CoreComponent { val json: Json + val userRefreshUI: UserRefreshUI + + @get:AppScope + @get:Provides + val lazyUserRefreshUIFactory: Lazy + get() = lazy { userRefreshUI } + @AppScope @Provides fun httpFactory( serverPreferences: ServerPreferences, + userPreferences: UserPreferences, + userRefreshUI: Lazy, json: Json, - ) = httpClient(serverPreferences, json) + ): Http = httpClient(serverPreferences, userPreferences, userRefreshUI, json) + + @AppScope + @Provides + fun httpNoAuthFactory( + serverPreferences: ServerPreferences, + userPreferences: UserPreferences, + userRefreshUI: Lazy, + json: Json, + ): HttpNoAuth = httpClientNoAuth(serverPreferences, userPreferences, userRefreshUI, json) @get:AppScope @get:Provides @@ -108,6 +132,11 @@ interface SharedDomainComponent : CoreComponent { val updatePreferencesFactory: UpdatePreferences get() = UpdatePreferences(preferenceFactory.create("update")) + @get:AppScope + @get:Provides + val userPreferencesFactory: UserPreferences + get() = UserPreferences(preferenceFactory.create("user")) + @get:AppScope @get:Provides val serverHostPreferencesFactory: ServerHostPreferences diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt index 8cccf45b..edafe948 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt @@ -6,10 +6,13 @@ package ca.gosyer.jui.domain.server +import ca.gosyer.jui.core.prefs.Preference import ca.gosyer.jui.domain.build.BuildKonfig import ca.gosyer.jui.domain.server.model.Auth import ca.gosyer.jui.domain.server.model.Proxy import ca.gosyer.jui.domain.server.service.ServerPreferences +import ca.gosyer.jui.domain.user.interactor.UserRefreshUI +import ca.gosyer.jui.domain.user.service.UserPreferences import com.diamondedge.logging.logging import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -17,6 +20,8 @@ import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.ProxyBuilder import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.api.Send +import io.ktor.client.plugins.api.createClientPlugin import io.ktor.client.plugins.auth.providers.BasicAuthCredentials import io.ktor.client.plugins.auth.providers.DigestAuthCredentials import io.ktor.client.plugins.auth.providers.basic @@ -27,6 +32,7 @@ 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.websocket.WebSockets +import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol import io.ktor.http.Url @@ -44,10 +50,65 @@ import io.ktor.client.plugins.auth.Auth as AuthPlugin typealias Http = StateFlow +typealias HttpNoAuth = StateFlow + expect val Engine: HttpClientEngineFactory expect fun HttpClientConfig.configurePlatform() +private val log = logging() + +private class SimpleAuthPluginConfig { + var simpleSessionPreference: Preference? = null +} + +private val SimpleAuthPlugin = createClientPlugin("SimpleAuthPlugin", ::SimpleAuthPluginConfig) { + val simpleSessionPreference = pluginConfig.simpleSessionPreference!! + + on(Send) { request -> + val session = simpleSessionPreference.get() + if (session.isNotEmpty()) { + request.headers.append("Cookie", "JSESSIONID=$session") + } + proceed(request) + } +} + +private class UIAuthPluginConfig { + var uiAccessTokenPreference: Preference? = null + var userRefreshUI: Lazy? = null +} + +private val UIAuthPlugin = createClientPlugin("UIAuthPlugin", ::UIAuthPluginConfig) { + val uiAccessTokenPreference = pluginConfig.uiAccessTokenPreference!! + val userRefreshUI = pluginConfig.userRefreshUI!! + + on(Send) { request -> + val token = uiAccessTokenPreference.get() + if (token.isNotEmpty()) { + request.headers.append("Authorization", "Bearer $token") + val originalCall = proceed(request) + if (originalCall.response.status == HttpStatusCode.Unauthorized) { + log.warn { "Token expired, refreshing..." } + val newToken = userRefreshUI.value.await() + if (newToken != null) { + request.headers.remove("Authorization") + request.headers.append("Authorization", "Bearer $newToken") + proceed(request) + } else { + originalCall + } + } else { + originalCall + } + } else { + proceed(request) + } + } +} + + + private fun getHttpClient( serverUrl: Url, proxy: Proxy, @@ -58,6 +119,9 @@ private fun getHttpClient( auth: Auth, authUsername: String, authPassword: String, + uiAccessTokenPreference: Preference, + simpleSessionPreference: Preference, + userRefreshUI: Lazy, json: Json ): HttpClient { return HttpClient(Engine) { @@ -113,6 +177,13 @@ private fun getHttpClient( } } } + Auth.SIMPLE -> install(SimpleAuthPlugin) { + this.simpleSessionPreference = simpleSessionPreference + } + Auth.UI -> install(UIAuthPlugin) { + this.uiAccessTokenPreference = uiAccessTokenPreference + this.userRefreshUI = userRefreshUI + } } install(HttpTimeout) { connectTimeoutMillis = 30.seconds.inWholeMilliseconds @@ -143,6 +214,8 @@ private fun getHttpClient( @OptIn(DelicateCoroutinesApi::class) fun httpClient( serverPreferences: ServerPreferences, + userPreferences: UserPreferences, + userRefreshUI: Lazy, json: Json, ): Http = combine( serverPreferences.serverUrl().stateIn(GlobalScope), @@ -156,30 +229,85 @@ fun httpClient( serverPreferences.authPassword().stateIn(GlobalScope), ) { getHttpClient( - it[0] as Url, - it[1] as Proxy, - it[2] as String, - it[3] as Int, - it[4] as String, - it[5] as Int, - it[6] as Auth, - it[7] as String, - it[8] as String, - json, + serverUrl = it[0] as Url, + proxy = it[1] as Proxy, + proxyHttpHost = it[2] as String, + proxyHttpPort = it[3] as Int, + proxySocksHost = it[4] as String, + proxySocksPort = it[5] as Int, + auth = it[6] as Auth, + authUsername = it[7] as String, + authPassword = it[8] as String, + uiAccessTokenPreference = userPreferences.uiAccessToken(), + simpleSessionPreference = userPreferences.simpleSession(), + userRefreshUI = userRefreshUI, + json = json, ) }.stateIn( GlobalScope, SharingStarted.Eagerly, getHttpClient( - serverPreferences.serverUrl().get(), - serverPreferences.proxy().get(), - serverPreferences.proxyHttpHost().get(), - serverPreferences.proxyHttpPort().get(), - serverPreferences.proxySocksHost().get(), - serverPreferences.proxySocksPort().get(), - serverPreferences.auth().get(), - serverPreferences.authUsername().get(), - serverPreferences.authPassword().get(), - json, + serverUrl = serverPreferences.serverUrl().get(), + proxy = serverPreferences.proxy().get(), + proxyHttpHost = serverPreferences.proxyHttpHost().get(), + proxyHttpPort = serverPreferences.proxyHttpPort().get(), + proxySocksHost = serverPreferences.proxySocksHost().get(), + proxySocksPort = serverPreferences.proxySocksPort().get(), + auth = serverPreferences.auth().get(), + authUsername = serverPreferences.authUsername().get(), + authPassword = serverPreferences.authPassword().get(), + uiAccessTokenPreference = userPreferences.uiAccessToken(), + simpleSessionPreference = userPreferences.simpleSession(), + userRefreshUI = userRefreshUI, + json = json, + ) +) + +@OptIn(DelicateCoroutinesApi::class) +fun httpClientNoAuth( + serverPreferences: ServerPreferences, + userPreferences: UserPreferences, + userRefreshUI: Lazy, + json: Json, +): Http = combine( + serverPreferences.serverUrl().stateIn(GlobalScope), + serverPreferences.proxy().stateIn(GlobalScope), + serverPreferences.proxyHttpHost().stateIn(GlobalScope), + serverPreferences.proxyHttpPort().stateIn(GlobalScope), + serverPreferences.proxySocksHost().stateIn(GlobalScope), + serverPreferences.proxySocksPort().stateIn(GlobalScope), +) { + getHttpClient( + serverUrl = it[0] as Url, + proxy = it[1] as Proxy, + proxyHttpHost = it[2] as String, + proxyHttpPort = it[3] as Int, + proxySocksHost = it[4] as String, + proxySocksPort = it[5] as Int, + auth = Auth.NONE, + authUsername = "", + authPassword = "", + uiAccessTokenPreference = userPreferences.uiAccessToken(), + simpleSessionPreference = userPreferences.simpleSession(), + userRefreshUI = userRefreshUI, + json = json, + ) +}.stateIn( + GlobalScope, + SharingStarted.Eagerly, + getHttpClient( + serverUrl = serverPreferences.serverUrl().get(), + proxy = serverPreferences.proxy().get(), + proxyHttpHost = serverPreferences.proxyHttpHost().get(), + proxyHttpPort = serverPreferences.proxyHttpPort().get(), + proxySocksHost = serverPreferences.proxySocksHost().get(), + proxySocksPort = serverPreferences.proxySocksPort().get(), + auth = Auth.NONE, + authUsername = "", + authPassword = "", + uiAccessTokenPreference = userPreferences.uiAccessToken(), + simpleSessionPreference = userPreferences.simpleSession(), + userRefreshUI = userRefreshUI, + json = json, ) ) diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt index 8281a28d..295b8eda 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt @@ -15,4 +15,6 @@ enum class Auth { NONE, BASIC, DIGEST, + SIMPLE, + UI, } diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLoginSimple.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLoginSimple.kt new file mode 100644 index 00000000..38ac4d95 --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLoginSimple.kt @@ -0,0 +1,38 @@ +package ca.gosyer.jui.domain.user.interactor + +import ca.gosyer.jui.domain.user.service.UserPreferences +import ca.gosyer.jui.domain.user.service.UserRepository +import com.diamondedge.logging.logging +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.singleOrNull +import me.tatarka.inject.annotations.Inject + +@Inject +class UserLoginSimple( + private val userRepository: UserRepository, + private val userPreferences: UserPreferences, +) { + suspend fun await( + username: String, + password: String, + onError: suspend (Throwable) -> Unit = {}, + ) = asFlow(username, password) + .catch { + onError(it) + log.warn(it) { "Failed to login with user $username" } + } + .singleOrNull() + + fun asFlow( + username: String, + password: String, + ) = userRepository.loginSimple(username, password) + .onEach { + userPreferences.simpleSession().set(it) + } + + companion object { + private val log = logging() + } +} diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLoginUI.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLoginUI.kt new file mode 100644 index 00000000..2d992fce --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLoginUI.kt @@ -0,0 +1,39 @@ +package ca.gosyer.jui.domain.user.interactor + +import ca.gosyer.jui.domain.user.service.UserPreferences +import ca.gosyer.jui.domain.user.service.UserRepository +import com.diamondedge.logging.logging +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.singleOrNull +import me.tatarka.inject.annotations.Inject + +@Inject +class UserLoginUI( + private val userRepository: UserRepository, + private val userPreferences: UserPreferences, +) { + suspend fun await( + username: String, + password: String, + onError: suspend (Throwable) -> Unit = {}, + ) = asFlow(username, password) + .catch { + onError(it) + log.warn(it) { "Failed to login with user $username" } + } + .singleOrNull() + + fun asFlow( + username: String, + password: String, + ) = userRepository.loginUI(username, password) + .onEach { + userPreferences.uiRefreshToken().set(it.refreshToken) + userPreferences.uiAccessToken().set(it.accessToken) + } + + companion object { + private val log = logging() + } +} diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLogout.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLogout.kt new file mode 100644 index 00000000..fb8d62c9 --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserLogout.kt @@ -0,0 +1,33 @@ +package ca.gosyer.jui.domain.user.interactor + +import ca.gosyer.jui.domain.user.service.UserPreferences +import com.diamondedge.logging.logging +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.singleOrNull +import me.tatarka.inject.annotations.Inject + +@Inject +class UserLogout( + private val userPreferences: UserPreferences, +) { + suspend fun await( + onError: suspend (Throwable) -> Unit = {}, + ) = asFlow() + .catch { + onError(it) + log.warn(it) { "Failed to logout user" } + } + .singleOrNull() + + fun asFlow() = flow { + userPreferences.uiRefreshToken().set("") + userPreferences.uiAccessToken().set("") + userPreferences.simpleSession().set("") + emit(Unit) + } + + companion object { + private val log = logging() + } +} diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserRefreshUI.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserRefreshUI.kt new file mode 100644 index 00000000..4d550fa7 --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/interactor/UserRefreshUI.kt @@ -0,0 +1,33 @@ +package ca.gosyer.jui.domain.user.interactor + +import ca.gosyer.jui.domain.user.service.UserPreferences +import ca.gosyer.jui.domain.user.service.UserRepository +import com.diamondedge.logging.logging +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.singleOrNull +import me.tatarka.inject.annotations.Inject + +@Inject +class UserRefreshUI( + private val userRepository: UserRepository, + private val userPreferences: UserPreferences, +) { + suspend fun await( + onError: suspend (Throwable) -> Unit = {}, + ) = asFlow() + .catch { + onError(it) + log.warn(it) { "Failed to refresh user" } + } + .singleOrNull() + + fun asFlow() = userRepository.refreshUI(userPreferences.uiRefreshToken().get()) + .onEach { + userPreferences.uiAccessToken().set(it) + } + + companion object { + private val log = logging() + } +} diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/model/LoginData.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/model/LoginData.kt new file mode 100644 index 00000000..ef476255 --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/model/LoginData.kt @@ -0,0 +1,6 @@ +package ca.gosyer.jui.domain.user.model + +data class LoginData( + val refreshToken: String, + val accessToken: String, +) diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt new file mode 100644 index 00000000..f78e38fe --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt @@ -0,0 +1,14 @@ +package ca.gosyer.jui.domain.user.service + +import ca.gosyer.jui.core.prefs.Preference +import ca.gosyer.jui.core.prefs.PreferenceStore + +class UserPreferences( + private val preferenceStore: PreferenceStore, +) { + fun uiRefreshToken(): Preference = preferenceStore.getString("ui_refresh_token", "") + + fun uiAccessToken(): Preference = preferenceStore.getString("ui_refresh_token", "") + + fun simpleSession(): Preference = preferenceStore.getString("simple_session", "") +} diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserRepository.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserRepository.kt new file mode 100644 index 00000000..6dfea2b2 --- /dev/null +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserRepository.kt @@ -0,0 +1,13 @@ +package ca.gosyer.jui.domain.user.service + +import ca.gosyer.jui.domain.user.model.LoginData +import kotlinx.coroutines.flow.Flow + +interface UserRepository { + + fun loginUI(username: String, password: String): Flow + + fun refreshUI(refreshToken: String): Flow + + fun loginSimple(username: String, password: String): Flow +} diff --git a/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/ServerHostPreferences.kt b/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/ServerHostPreferences.kt index e74b2da0..70697ed4 100644 --- a/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/ServerHostPreferences.kt +++ b/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/ServerHostPreferences.kt @@ -9,6 +9,7 @@ package ca.gosyer.jui.domain.server.service import ca.gosyer.jui.core.prefs.Preference import ca.gosyer.jui.core.prefs.PreferenceStore import ca.gosyer.jui.domain.server.service.host.ServerHostPreference +import ca.gosyer.jui.domain.settings.model.AuthMode actual class ServerHostPreferences actual constructor( private val preferenceStore: PreferenceStore, @@ -17,46 +18,48 @@ actual class ServerHostPreferences actual constructor( // IP private val ip = ServerHostPreference.IP(preferenceStore) - fun ip(): Preference = ip.preference() private val port = ServerHostPreference.Port(preferenceStore) - fun port(): Preference = port.preference() // Root private val rootPath = ServerHostPreference.RootPath(preferenceStore) - fun rootPath(): Preference = rootPath.preference() // Downloader private val downloadPath = ServerHostPreference.DownloadPath(preferenceStore) - fun downloadPath(): Preference = downloadPath.preference() // Backup private val backupPath = ServerHostPreference.BackupPath(preferenceStore) - fun backupPath(): Preference = backupPath.preference() // LocalSource private val localSourcePath = ServerHostPreference.LocalSourcePath(preferenceStore) - fun localSourcePath(): Preference = localSourcePath.preference() // Authentication private val basicAuthEnabled = ServerHostPreference.BasicAuthEnabled(preferenceStore) - + @Deprecated("") fun basicAuthEnabled(): Preference = basicAuthEnabled.preference() - private val basicAuthUsername = ServerHostPreference.BasicAuthUsername(preferenceStore) - + @Deprecated("") fun basicAuthUsername(): Preference = basicAuthUsername.preference() - private val basicAuthPassword = ServerHostPreference.BasicAuthPassword(preferenceStore) - + @Deprecated("") fun basicAuthPassword(): Preference = basicAuthPassword.preference() + // Authentication + private val authMode = ServerHostPreference.AuthMode(preferenceStore) + fun authMode(): Preference = authMode.preference() + + private val authUsername = ServerHostPreference.AuthUsername(preferenceStore) + fun authUsername(): Preference = authUsername.preference() + + private val authPassword = ServerHostPreference.AuthPassword(preferenceStore) + fun authPassword(): Preference = authPassword.preference() + fun properties(): Array = listOf( ip, @@ -65,9 +68,9 @@ actual class ServerHostPreferences actual constructor( downloadPath, backupPath, localSourcePath, - basicAuthEnabled, - basicAuthUsername, - basicAuthPassword, + authMode, + authUsername, + authPassword, ).mapNotNull { it.getProperty() }.plus( diff --git a/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/host/ServerHostPreference.kt b/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/host/ServerHostPreference.kt index a779799c..125ab625 100644 --- a/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/host/ServerHostPreference.kt +++ b/domain/src/desktopMain/kotlin/ca/gosyer/jui/domain/server/service/host/ServerHostPreference.kt @@ -8,6 +8,8 @@ package ca.gosyer.jui.domain.server.service.host import ca.gosyer.jui.core.prefs.Preference import ca.gosyer.jui.core.prefs.PreferenceStore +import ca.gosyer.jui.core.prefs.getEnum +import ca.gosyer.jui.domain.settings.model.AuthMode as ServerAuthMode sealed class ServerHostPreference { protected abstract val propertyName: String @@ -63,6 +65,16 @@ sealed class ServerHostPreference { override fun preference(): Preference = preferenceStore.getBoolean(propertyName, defaultValue) } + sealed class ObjectServerHostPreference( + override val preferenceStore: PreferenceStore, + override val propertyName: String, + override val defaultValue: T, + override val serverValue: T = defaultValue, + private val getObject: (String, T) -> Preference, + ) : ServerHostPreference() { + override fun preference(): Preference = getObject(propertyName, defaultValue) + } + // Root class RootPath( preferenceStore: PreferenceStore, @@ -116,27 +128,43 @@ sealed class ServerHostPreference { ) // Authentication + @Deprecated("UseAuthMode") class BasicAuthEnabled( preferenceStore: PreferenceStore, - ) : BooleanServerHostPreference( - preferenceStore, - "basicAuthEnabled", - false, - ) - + ) : BooleanServerHostPreference(preferenceStore, "basicAuthEnabled", false) + @Deprecated("UseAuthUsername") class BasicAuthUsername( preferenceStore: PreferenceStore, - ) : StringServerHostPreference( - preferenceStore, - "basicAuthUsername", - "", - ) - + ) : StringServerHostPreference(preferenceStore, "basicAuthUsername", "") + @Deprecated("UseAuthPassword") class BasicAuthPassword( preferenceStore: PreferenceStore, + ) : StringServerHostPreference(preferenceStore, "basicAuthPassword", "") + + class AuthMode( + preferenceStore: PreferenceStore, + ) : ObjectServerHostPreference( + preferenceStore, + "authMode", + ServerAuthMode.NONE, + getObject = { propertyName, default -> + preferenceStore.getEnum(propertyName, default) + } + ) + + class AuthUsername( + preferenceStore: PreferenceStore, ) : StringServerHostPreference( - preferenceStore, - "basicAuthPassword", - "", - ) + preferenceStore, + "authUsername", + "", + ) + + class AuthPassword( + preferenceStore: PreferenceStore, + ) : StringServerHostPreference( + preferenceStore, + "authPassword", + "", + ) } diff --git a/i18n/src/commonMain/moko-resources/values/base/strings.xml b/i18n/src/commonMain/moko-resources/values/base/strings.xml index c1d4cadf..c8e0cca7 100644 --- a/i18n/src/commonMain/moko-resources/values/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/values/base/strings.xml @@ -319,9 +319,10 @@ Whether the server\'s default WebUI is enabled, makes you able to use Suwayomi in your browser Open Server WebUI on startup Open the WebUI inside your browser on server startup. Requires the WebUI be enabled - Use basic auth to protect your library, requires username and password - Basic auth username - Basic auth password + Auth mode + Use authentication to protect your library, requires username and password + Auth username + Auth password Server URL Server PORT Server Path Prefix @@ -338,6 +339,10 @@ No auth Basic auth Digest auth + Simple auth + UI login + Login + Logout Auth username Auth password The below settings configure the connected Suwayomi-Server diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt index 49d34953..d8ecef48 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon @@ -35,6 +36,8 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Login +import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Info @@ -46,10 +49,15 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import ca.gosyer.jui.core.lang.launchIO @@ -59,8 +67,13 @@ import ca.gosyer.jui.domain.server.service.ServerHostPreferences import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.settings.interactor.GetSettings import ca.gosyer.jui.domain.settings.interactor.SetSettings +import ca.gosyer.jui.domain.settings.model.AuthMode import ca.gosyer.jui.domain.settings.model.SetSettingsInput import ca.gosyer.jui.domain.settings.model.Settings +import ca.gosyer.jui.domain.user.interactor.UserLoginSimple +import ca.gosyer.jui.domain.user.interactor.UserLoginUI +import ca.gosyer.jui.domain.user.interactor.UserLogout +import ca.gosyer.jui.domain.user.service.UserPreferences import ca.gosyer.jui.i18n.MR import ca.gosyer.jui.ui.base.dialog.getMaterialDialogProperties import ca.gosyer.jui.ui.base.navigation.Toolbar @@ -95,10 +108,13 @@ import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.datetime.LocalTime import kotlinx.datetime.format.char @@ -127,8 +143,12 @@ class SettingsServerScreen : Screen { authChoices = connectionVM.getAuthChoices(), authUsername = connectionVM.authUsername, authPassword = connectionVM.authPassword, + authUILoggedIn = connectionVM.authUILoggedIn.collectAsState().value, + authSimpleLoggedIn = connectionVM.authSimpleLoggedIn.collectAsState().value, serverSettings = connectionVM.serverSettings.collectAsState().value, hosted = connectionVM.host.collectAsState().value, + onLogin = connectionVM::onLogin, + onLogout = connectionVM::onLogout, ) } } @@ -194,18 +214,18 @@ class ServerSettings( getInput = { SetSettingsInput(backupTime = it) }, ) - // val basicAuthEnabled = getServerFlow( -// getSetting = { it.basicAuthEnabled }, -// getInput = { SetSettingsInput(basicAuthEnabled = it) }, -// ) -// val basicAuthPassword = getServerFlow( -// getSetting = { it.basicAuthPassword }, -// getInput = { SetSettingsInput(basicAuthPassword = it) }, -// ) -// val basicAuthUsername = getServerFlow( -// getSetting = { it.basicAuthUsername }, -// getInput = { SetSettingsInput(basicAuthUsername = it) }, -// ) + val authMode = getServerFlow( + getSetting = { it.authMode }, + getInput = { SetSettingsInput(authMode = it) }, + ) + val authPassword = getServerFlow( + getSetting = { it.authPassword }, + getInput = { SetSettingsInput(authPassword = it) }, + ) + val authUsername = getServerFlow( + getSetting = { it.authUsername }, + getInput = { SetSettingsInput(authUsername = it) }, + ) val debugLogsEnabled = getServerFlow( getSetting = { it.debugLogsEnabled }, getInput = { SetSettingsInput(debugLogsEnabled = it) }, @@ -370,6 +390,10 @@ class SettingsServerViewModel( private val setSettings: SetSettings, serverPreferences: ServerPreferences, serverHostPreferences: ServerHostPreferences, + userPreferences: UserPreferences, + private val userLoginSimple: UserLoginSimple, + private val userLoginUi: UserLoginUI, + private val userLogout: UserLogout, contextWrapper: ContextWrapper, ) : ViewModel(contextWrapper) { val serverUrl = serverPreferences.server().asStateIn(scope) @@ -394,6 +418,12 @@ class SettingsServerViewModel( val socksPort = serverPreferences.proxySocksPort().asStringStateIn(scope) val auth = serverPreferences.auth().asStateIn(scope) + val authUILoggedIn = userPreferences.uiRefreshToken().changes() + .map { it.isNotBlank() } + .stateIn(scope, SharingStarted.Eagerly, userPreferences.uiRefreshToken().get().isNotBlank()) + val authSimpleLoggedIn = userPreferences.simpleSession().changes() + .map { it.isNotBlank() } + .stateIn(scope, SharingStarted.Eagerly, userPreferences.simpleSession().get().isNotBlank()) @Composable fun getAuthChoices(): ImmutableMap = @@ -401,11 +431,31 @@ class SettingsServerViewModel( Auth.NONE to stringResource(MR.strings.no_auth), Auth.BASIC to stringResource(MR.strings.basic_auth), Auth.DIGEST to stringResource(MR.strings.digest_auth), + Auth.SIMPLE to stringResource(MR.strings.simple_auth), + Auth.UI to stringResource(MR.strings.ui_login), ) val authUsername = serverPreferences.authUsername().asStateIn(scope) val authPassword = serverPreferences.authPassword().asStateIn(scope) + fun onLogin(username: String, password: String) { + when (auth.value) { + Auth.SIMPLE -> { + userLoginSimple.asFlow(username, password) + .launchIn(scope) + } + Auth.UI -> { + userLoginUi.asFlow(username, password) + .launchIn(scope) + } + else -> Unit + } + } + + fun onLogout() { + userLogout.asFlow().launchIn(scope) + } + private val _serverSettings = MutableStateFlow(null) val serverSettings = _serverSettings.asStateFlow() @@ -443,8 +493,12 @@ fun SettingsServerScreenContent( authChoices: ImmutableMap, authUsername: PreferenceMutableStateFlow, authPassword: PreferenceMutableStateFlow, + authUILoggedIn: Boolean, + authSimpleLoggedIn: Boolean, hosted: Boolean, serverSettings: ServerSettings?, + onLogin: (String, String) -> Unit, + onLogout: () -> Unit, ) { Scaffold( modifier = Modifier.windowInsetsPadding( @@ -540,18 +594,95 @@ fun SettingsServerScreenContent( item { ChoicePreference(auth, authChoices, stringResource(MR.strings.authentication)) } - if (authValue != Auth.NONE) { - item { - EditTextPreference(authUsername, stringResource(MR.strings.auth_username)) - } - item { - EditTextPreference( - authPassword, - stringResource(MR.strings.auth_password), - visualTransformation = PasswordVisualTransformation(), - ) + + when (authValue) { + Auth.NONE -> Unit + Auth.BASIC, Auth.DIGEST, Auth.UI, Auth.SIMPLE -> { + item { + EditTextPreference(authUsername, stringResource(MR.strings.auth_username)) + } + item { + EditTextPreference( + authPassword, + stringResource(MR.strings.auth_password), + visualTransformation = PasswordVisualTransformation(), + ) + } } } + + if (authValue == Auth.UI || authValue == Auth.SIMPLE) { + if ((authValue == Auth.SIMPLE && authSimpleLoggedIn) || (authValue == Auth.UI && authUILoggedIn)) { + item { + PreferenceRow( + stringResource(MR.strings.logout), + icon = Icons.AutoMirrored.Rounded.Logout, + onClick = onLogout, + ) + } + } else { + item { + val loginDialogState = rememberMaterialDialogState() + PreferenceRow( + stringResource(MR.strings.login), + icon = Icons.AutoMirrored.Rounded.Login, + onClick = { + loginDialogState.show() + }, + ) + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + MaterialDialog( + dialogState = loginDialogState, + properties = getMaterialDialogProperties(), + buttons = { + negativeButton(stringResource(MR.strings.action_cancel)) + positiveButton( + stringResource(MR.strings.login), + onClick = { + onLogin(username, password) + }, + ) + }, + ) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + modifier = Modifier + .semantics { contentType = ContentType.Username } + .keyboardHandler( + singleLine = true, + enterAction = { + submit() + }, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + ), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + modifier = Modifier + .semantics { contentType = ContentType.Password } + .keyboardHandler( + singleLine = true, + enterAction = { + submit() + }, + ), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + ), + ) + } + } + } + } + + item { Divider() } @@ -814,32 +945,41 @@ fun LazyListScope.ServerSettingsItems( ) } -// item { -// SwitchPreference( -// preference = serverSettings.basicAuthEnabled, -// title = stringResource(MR.strings.basic_auth), -// subtitle = stringResource(MR.strings.host_basic_auth_sub), -// enabled = !hosted, -// ) -// } -// -// item { -// val basicAuthEnabledValue by serverSettings.basicAuthEnabled.collectAsState() -// EditTextPreference( -// preference = serverSettings.basicAuthUsername, -// title = stringResource(MR.strings.host_basic_auth_username), -// enabled = basicAuthEnabledValue && !hosted, -// ) -// } -// item { -// val basicAuthEnabledValue by serverSettings.basicAuthEnabled.collectAsState() -// EditTextPreference( -// preference = serverSettings.basicAuthPassword, -// title = stringResource(MR.strings.host_basic_auth_password), -// visualTransformation = PasswordVisualTransformation(), -// enabled = basicAuthEnabledValue && !hosted, -// ) -// } + item { + ChoicePreference( + preference = serverSettings.authMode, + title = stringResource(MR.strings.host_auth), + subtitle = stringResource(MR.strings.host_auth_sub), + choices = (AuthMode.entries - AuthMode.UNKNOWN__).associateWith { + when (it) { + AuthMode.NONE -> stringResource(MR.strings.no_auth) + AuthMode.BASIC_AUTH -> stringResource(MR.strings.basic_auth) + AuthMode.SIMPLE_LOGIN -> stringResource(MR.strings.simple_auth) + AuthMode.UI_LOGIN -> stringResource(MR.strings.ui_login) + AuthMode.UNKNOWN__ -> "" + } + }.toImmutableMap(), + enabled = !hosted, + ) + } + + item { + val authModeValue by serverSettings.authMode.collectAsState() + EditTextPreference( + preference = serverSettings.authUsername, + title = stringResource(MR.strings.host_auth_username), + enabled = authModeValue != AuthMode.NONE && !hosted, + ) + } + item { + val authModeValue by serverSettings.authMode.collectAsState() + EditTextPreference( + preference = serverSettings.authPassword, + title = stringResource(MR.strings.host_auth_password), + visualTransformation = PasswordVisualTransformation(), + enabled = authModeValue != AuthMode.NONE && !hosted, + ) + } item { SwitchPreference( preference = serverSettings.flareSolverrEnabled, diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/settings/DesktopSettingsServerScreen.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/settings/DesktopSettingsServerScreen.kt index 2252c690..adc31ab5 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/settings/DesktopSettingsServerScreen.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/settings/DesktopSettingsServerScreen.kt @@ -19,7 +19,9 @@ import ca.gosyer.jui.domain.server.model.Auth import ca.gosyer.jui.domain.server.service.ServerHostPreferences import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.server.service.ServerService +import ca.gosyer.jui.domain.settings.model.AuthMode import ca.gosyer.jui.i18n.MR +import ca.gosyer.jui.ui.base.prefs.ChoicePreference import ca.gosyer.jui.ui.base.prefs.EditTextPreference import ca.gosyer.jui.ui.base.prefs.PreferenceRow import ca.gosyer.jui.ui.base.prefs.SwitchPreference @@ -29,6 +31,7 @@ import ca.gosyer.jui.uicore.prefs.asStringStateIn import ca.gosyer.jui.uicore.resources.stringResource import ca.gosyer.jui.uicore.vm.ContextWrapper import ca.gosyer.jui.uicore.vm.ViewModel +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -39,7 +42,7 @@ import me.tatarka.inject.annotations.Inject actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostViewModel): LazyListScope.() -> Unit { val serverVm = viewModel() val hostValue by serverVm.host.collectAsState() - val basicAuthEnabledValue by serverVm.basicAuthEnabled.collectAsState() + val authMode by serverVm.hostAuthMode.collectAsState() DisposableEffect(Unit) { onDispose { @@ -50,7 +53,7 @@ actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostVie return { ServerHostItems( hostValue = hostValue, - basicAuthEnabledValue = basicAuthEnabledValue, + authEnabledValue = authMode != AuthMode.NONE, serverSettingChanged = serverVm::serverSettingChanged, host = serverVm.host, ip = serverVm.ip, @@ -59,9 +62,9 @@ actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostVie downloadPath = serverVm.downloadPath, backupPath = serverVm.backupPath, localSourcePath = serverVm.localSourcePath, - basicAuthEnabled = serverVm.basicAuthEnabled, - basicAuthUsername = serverVm.basicAuthUsername, - basicAuthPassword = serverVm.basicAuthPassword, + authMode = serverVm.hostAuthMode, + authUsername = serverVm.hostAuthUsername, + authPassword = serverVm.hostAuthPassword, ) } } @@ -92,9 +95,9 @@ actual class SettingsServerHostViewModel( val localSourcePath = serverHostPreferences.localSourcePath().asStateIn(scope) // Authentication - val basicAuthEnabled = serverHostPreferences.basicAuthEnabled().asStateIn(scope) - val basicAuthUsername = serverHostPreferences.basicAuthUsername().asStateIn(scope) - val basicAuthPassword = serverHostPreferences.basicAuthPassword().asStateIn(scope) + val hostAuthMode = serverHostPreferences.authMode().asStateIn(scope) + val hostAuthUsername = serverHostPreferences.authUsername().asStateIn(scope) + val hostAuthPassword = serverHostPreferences.authPassword().asStateIn(scope) private val _serverSettingChanged = MutableStateFlow(false) val serverSettingChanged = _serverSettingChanged.asStateFlow() @@ -115,16 +118,18 @@ actual class SettingsServerHostViewModel( val authPassword = serverPreferences.authPassword().asStateIn(scope) init { - combine(host, basicAuthEnabled, basicAuthUsername, basicAuthPassword) { host, enabled, username, password -> + combine(host, hostAuthMode, hostAuthUsername, hostAuthPassword) { host, mode, username, password -> if (host) { - if (enabled) { - auth.value = Auth.BASIC - authUsername.value = username - authPassword.value = password - } else { - auth.value = Auth.NONE - authUsername.value = "" - authPassword.value = "" + when (mode) { + AuthMode.NONE -> auth.value = Auth.NONE + AuthMode.BASIC_AUTH -> { + auth.value = Auth.BASIC + authUsername.value = username + authPassword.value = password + } + AuthMode.SIMPLE_LOGIN -> auth.value = Auth.SIMPLE + AuthMode.UI_LOGIN -> auth.value = Auth.UI + AuthMode.UNKNOWN__ -> Unit } } }.launchIn(scope) @@ -133,7 +138,7 @@ actual class SettingsServerHostViewModel( fun LazyListScope.ServerHostItems( hostValue: Boolean, - basicAuthEnabledValue: Boolean, + authEnabledValue: Boolean, serverSettingChanged: () -> Unit, host: MutableStateFlow, ip: MutableStateFlow, @@ -142,9 +147,9 @@ fun LazyListScope.ServerHostItems( downloadPath: MutableStateFlow, backupPath: MutableStateFlow, localSourcePath: MutableStateFlow, - basicAuthEnabled: MutableStateFlow, - basicAuthUsername: MutableStateFlow, - basicAuthPassword: MutableStateFlow, + authMode: MutableStateFlow, + authUsername: MutableStateFlow, + authPassword: MutableStateFlow, ) { item { SwitchPreference(preference = host, title = stringResource(MR.strings.host_server)) @@ -260,28 +265,37 @@ fun LazyListScope.ServerHostItems( ) } item { - SwitchPreference( - preference = basicAuthEnabled, - title = stringResource(MR.strings.basic_auth), - subtitle = stringResource(MR.strings.host_basic_auth_sub), + ChoicePreference( + preference = authMode, + title = stringResource(MR.strings.host_auth), + subtitle = stringResource(MR.strings.host_auth_sub), + choices = (AuthMode.entries - AuthMode.UNKNOWN__).associateWith { + when (it) { + AuthMode.NONE -> stringResource(MR.strings.no_auth) + AuthMode.BASIC_AUTH -> stringResource(MR.strings.basic_auth) + AuthMode.SIMPLE_LOGIN -> stringResource(MR.strings.simple_auth) + AuthMode.UI_LOGIN -> stringResource(MR.strings.ui_login) + AuthMode.UNKNOWN__ -> "" + } + }.toImmutableMap(), changeListener = serverSettingChanged, ) } item { EditTextPreference( - preference = basicAuthUsername, - title = stringResource(MR.strings.host_basic_auth_username), + preference = authUsername, + title = stringResource(MR.strings.host_auth_username), changeListener = serverSettingChanged, - enabled = basicAuthEnabledValue, + enabled = authEnabledValue, ) } item { EditTextPreference( - preference = basicAuthPassword, - title = stringResource(MR.strings.host_basic_auth_password), + preference = authPassword, + title = stringResource(MR.strings.host_auth_password), changeListener = serverSettingChanged, visualTransformation = PasswordVisualTransformation(), - enabled = basicAuthEnabledValue, + enabled = authEnabledValue, ) } }