Initial auth support(ws currently broken, bad login dialog)

This commit is contained in:
Syer10
2025-10-15 16:23:11 -04:00
parent 0245ef25fe
commit f0a9f7aff1
21 changed files with 790 additions and 139 deletions

View File

@@ -1,7 +1,7 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
object Config { object Config {
const val migrationCode = 5 const val migrationCode = 6
// Suwayomi-Server version // Suwayomi-Server version
const val tachideskVersion = "v2.1.1959" const val tachideskVersion = "v2.1.1959"

View File

@@ -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
}
}

View File

@@ -18,6 +18,7 @@ import ca.gosyer.jui.data.manga.MangaRepositoryImpl
import ca.gosyer.jui.data.settings.SettingsRepositoryImpl import ca.gosyer.jui.data.settings.SettingsRepositoryImpl
import ca.gosyer.jui.data.source.SourceRepositoryImpl import ca.gosyer.jui.data.source.SourceRepositoryImpl
import ca.gosyer.jui.data.updates.UpdatesRepositoryImpl 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.backup.service.BackupRepository
import ca.gosyer.jui.domain.category.service.CategoryRepository import ca.gosyer.jui.domain.category.service.CategoryRepository
import ca.gosyer.jui.domain.chapter.service.ChapterRepository 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.library.service.LibraryRepository
import ca.gosyer.jui.domain.manga.service.MangaRepository import ca.gosyer.jui.domain.manga.service.MangaRepository
import ca.gosyer.jui.domain.server.Http 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.server.service.ServerPreferences
import ca.gosyer.jui.domain.settings.service.SettingsRepository import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.source.service.SourceRepository import ca.gosyer.jui.domain.source.service.SourceRepository
import ca.gosyer.jui.domain.updates.service.UpdatesRepository 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.ApolloClient
import com.apollographql.apollo.annotations.ApolloExperimental
import com.apollographql.apollo.network.ws.GraphQLWsProtocol import com.apollographql.apollo.network.ws.GraphQLWsProtocol
import com.apollographql.ktor.ktorClient import com.apollographql.ktor.ktorClient
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -50,6 +52,8 @@ import me.tatarka.inject.annotations.Provides
typealias ApolloAppClient = StateFlow<ApolloClient> typealias ApolloAppClient = StateFlow<ApolloClient>
typealias ApolloAppClientNoAuth = StateFlow<ApolloClient>
private fun getApolloClient( private fun getApolloClient(
httpClient: HttpClient, httpClient: HttpClient,
serverUrl: Url, serverUrl: Url,
@@ -66,7 +70,7 @@ private fun getApolloClient(
} }
interface DataComponent : SharedDataComponent { interface DataComponent : SharedDataComponent {
@OptIn(ApolloExperimental::class)
@Provides @Provides
@AppScope @AppScope
fun apolloAppClient( fun apolloAppClient(
@@ -81,6 +85,20 @@ interface DataComponent : SharedDataComponent {
getApolloClient(http.value, serverPreferences.serverUrl().get()), 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 @Provides
fun settingsRepository(apolloAppClient: ApolloAppClient): SettingsRepository = SettingsRepositoryImpl(apolloAppClient) fun settingsRepository(apolloAppClient: ApolloAppClient): SettingsRepository = SettingsRepositoryImpl(apolloAppClient)
@@ -147,6 +165,15 @@ interface DataComponent : SharedDataComponent {
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): UpdatesRepository = UpdatesRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get()) ): 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 @Provides
fun backupRepository( fun backupRepository(
apolloAppClient: ApolloAppClient, apolloAppClient: ApolloAppClient,

View File

@@ -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<LoginData> {
return apolloClientNoAuth.mutation(
LoginMutation(username, password),
)
.toFlow()
.map {
val data = it.dataAssertNoErrors.login
LoginData(data.refreshToken, data.accessToken)
}
}
override fun refreshUI(refreshToken: String): Flow<String> {
return apolloClientNoAuth.mutation(
RefreshAccessTokenMutation(refreshToken),
)
.toFlow()
.map {
val data = it.dataAssertNoErrors.refreshToken
data.accessToken
}
}
override fun loginSimple(username: String, password: String): Flow<String> {
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(";"))
}
}
}

View File

@@ -29,7 +29,7 @@ abstract class AppComponent(
@get:AppScope @get:AppScope
@get:Provides @get:Provides
protected val appMigrationsFactory: AppMigrations protected val appMigrationsFactory: AppMigrations
get() = AppMigrations(migrationPreferences, contextWrapper) get() = AppMigrations(migrationPreferences, serverHostPreferences, contextWrapper)
val bind: ViewModelComponent val bind: ViewModelComponent
@Provides get() = this @Provides get() = this

View File

@@ -9,6 +9,8 @@ package ca.gosyer.jui.desktop
import ca.gosyer.appdirs.AppDirs import ca.gosyer.appdirs.AppDirs
import ca.gosyer.jui.desktop.build.BuildConfig import ca.gosyer.jui.desktop.build.BuildConfig
import ca.gosyer.jui.domain.migration.service.MigrationPreferences 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 ca.gosyer.jui.uicore.vm.ContextWrapper
import com.diamondedge.logging.logging import com.diamondedge.logging.logging
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
@@ -18,6 +20,7 @@ import okio.Path.Companion.toPath
@Inject @Inject
class AppMigrations( class AppMigrations(
private val migrationPreferences: MigrationPreferences, private val migrationPreferences: MigrationPreferences,
private val serverHostPreference: ServerHostPreferences,
private val contextWrapper: ContextWrapper, private val contextWrapper: ContextWrapper,
) { ) {
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
@@ -32,8 +35,8 @@ class AppMigrations(
} }
if (oldVersion < 5) { if (oldVersion < 5) {
val oldDir = AppDirs("Tachidesk-JUI").getUserDataDir().toPath() val oldDir = AppDirs { appName = "Tachidesk-JUI" }.getUserDataDir().toPath()
val newDir = AppDirs("Suwayomi-JUI").getUserDataDir().toPath() val newDir = AppDirs { appName = "Suwayomi-JUI" }.getUserDataDir().toPath()
try { try {
FileSystem.SYSTEM.list(oldDir) FileSystem.SYSTEM.list(oldDir)
.filter { FileSystem.SYSTEM.metadata(it).isDirectory } .filter { FileSystem.SYSTEM.metadata(it).isDirectory }
@@ -48,6 +51,14 @@ class AppMigrations(
log.e(e) { "Failed to run directory migration" } 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 return true
} }

View File

@@ -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.migration.service.MigrationPreferences
import ca.gosyer.jui.domain.reader.service.ReaderPreferences import ca.gosyer.jui.domain.reader.service.ReaderPreferences
import ca.gosyer.jui.domain.server.Http 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.httpClient
import ca.gosyer.jui.domain.server.httpClientNoAuth
import ca.gosyer.jui.domain.server.service.ServerHostPreferences import ca.gosyer.jui.domain.server.service.ServerHostPreferences
import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.source.service.CatalogPreferences import ca.gosyer.jui.domain.source.service.CatalogPreferences
import ca.gosyer.jui.domain.ui.service.UiPreferences import ca.gosyer.jui.domain.ui.service.UiPreferences
import ca.gosyer.jui.domain.updates.interactor.UpdateChecker import ca.gosyer.jui.domain.updates.interactor.UpdateChecker
import ca.gosyer.jui.domain.updates.service.UpdatePreferences 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 kotlinx.serialization.json.Json
import me.tatarka.inject.annotations.Provides import me.tatarka.inject.annotations.Provides
@@ -39,6 +43,8 @@ interface SharedDomainComponent : CoreComponent {
val http: Http val http: Http
val httpNoAuth: HttpNoAuth
val serverPreferences: ServerPreferences val serverPreferences: ServerPreferences
val extensionPreferences: ExtensionPreferences val extensionPreferences: ExtensionPreferences
@@ -59,12 +65,30 @@ interface SharedDomainComponent : CoreComponent {
val json: Json val json: Json
val userRefreshUI: UserRefreshUI
@get:AppScope
@get:Provides
val lazyUserRefreshUIFactory: Lazy<UserRefreshUI>
get() = lazy { userRefreshUI }
@AppScope @AppScope
@Provides @Provides
fun httpFactory( fun httpFactory(
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json, json: Json,
) = httpClient(serverPreferences, json) ): Http = httpClient(serverPreferences, userPreferences, userRefreshUI, json)
@AppScope
@Provides
fun httpNoAuthFactory(
serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json,
): HttpNoAuth = httpClientNoAuth(serverPreferences, userPreferences, userRefreshUI, json)
@get:AppScope @get:AppScope
@get:Provides @get:Provides
@@ -108,6 +132,11 @@ interface SharedDomainComponent : CoreComponent {
val updatePreferencesFactory: UpdatePreferences val updatePreferencesFactory: UpdatePreferences
get() = UpdatePreferences(preferenceFactory.create("update")) get() = UpdatePreferences(preferenceFactory.create("update"))
@get:AppScope
@get:Provides
val userPreferencesFactory: UserPreferences
get() = UserPreferences(preferenceFactory.create("user"))
@get:AppScope @get:AppScope
@get:Provides @get:Provides
val serverHostPreferencesFactory: ServerHostPreferences val serverHostPreferencesFactory: ServerHostPreferences

View File

@@ -6,10 +6,13 @@
package ca.gosyer.jui.domain.server 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.build.BuildKonfig
import ca.gosyer.jui.domain.server.model.Auth import ca.gosyer.jui.domain.server.model.Auth
import ca.gosyer.jui.domain.server.model.Proxy import ca.gosyer.jui.domain.server.model.Proxy
import ca.gosyer.jui.domain.server.service.ServerPreferences 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 com.diamondedge.logging.logging
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig 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.HttpClientEngineFactory
import io.ktor.client.engine.ProxyBuilder import io.ktor.client.engine.ProxyBuilder
import io.ktor.client.plugins.HttpTimeout 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.BasicAuthCredentials
import io.ktor.client.plugins.auth.providers.DigestAuthCredentials import io.ktor.client.plugins.auth.providers.DigestAuthCredentials
import io.ktor.client.plugins.auth.providers.basic 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.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.http.HttpStatusCode
import io.ktor.http.URLBuilder import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol import io.ktor.http.URLProtocol
import io.ktor.http.Url import io.ktor.http.Url
@@ -44,10 +50,65 @@ import io.ktor.client.plugins.auth.Auth as AuthPlugin
typealias Http = StateFlow<HttpClient> typealias Http = StateFlow<HttpClient>
typealias HttpNoAuth = StateFlow<HttpClient>
expect val Engine: HttpClientEngineFactory<HttpClientEngineConfig> expect val Engine: HttpClientEngineFactory<HttpClientEngineConfig>
expect fun HttpClientConfig<HttpClientEngineConfig>.configurePlatform() expect fun HttpClientConfig<HttpClientEngineConfig>.configurePlatform()
private val log = logging()
private class SimpleAuthPluginConfig {
var simpleSessionPreference: Preference<String>? = 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<String>? = null
var userRefreshUI: Lazy<UserRefreshUI>? = 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( private fun getHttpClient(
serverUrl: Url, serverUrl: Url,
proxy: Proxy, proxy: Proxy,
@@ -58,6 +119,9 @@ private fun getHttpClient(
auth: Auth, auth: Auth,
authUsername: String, authUsername: String,
authPassword: String, authPassword: String,
uiAccessTokenPreference: Preference<String>,
simpleSessionPreference: Preference<String>,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json json: Json
): HttpClient { ): HttpClient {
return HttpClient(Engine) { 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) { install(HttpTimeout) {
connectTimeoutMillis = 30.seconds.inWholeMilliseconds connectTimeoutMillis = 30.seconds.inWholeMilliseconds
@@ -143,6 +214,8 @@ private fun getHttpClient(
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun httpClient( fun httpClient(
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json, json: Json,
): Http = combine( ): Http = combine(
serverPreferences.serverUrl().stateIn(GlobalScope), serverPreferences.serverUrl().stateIn(GlobalScope),
@@ -156,30 +229,85 @@ fun httpClient(
serverPreferences.authPassword().stateIn(GlobalScope), serverPreferences.authPassword().stateIn(GlobalScope),
) { ) {
getHttpClient( getHttpClient(
it[0] as Url, serverUrl = it[0] as Url,
it[1] as Proxy, proxy = it[1] as Proxy,
it[2] as String, proxyHttpHost = it[2] as String,
it[3] as Int, proxyHttpPort = it[3] as Int,
it[4] as String, proxySocksHost = it[4] as String,
it[5] as Int, proxySocksPort = it[5] as Int,
it[6] as Auth, auth = it[6] as Auth,
it[7] as String, authUsername = it[7] as String,
it[8] as String, authPassword = it[8] as String,
json, uiAccessTokenPreference = userPreferences.uiAccessToken(),
simpleSessionPreference = userPreferences.simpleSession(),
userRefreshUI = userRefreshUI,
json = json,
) )
}.stateIn( }.stateIn(
GlobalScope, GlobalScope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
getHttpClient( getHttpClient(
serverPreferences.serverUrl().get(), serverUrl = serverPreferences.serverUrl().get(),
serverPreferences.proxy().get(), proxy = serverPreferences.proxy().get(),
serverPreferences.proxyHttpHost().get(), proxyHttpHost = serverPreferences.proxyHttpHost().get(),
serverPreferences.proxyHttpPort().get(), proxyHttpPort = serverPreferences.proxyHttpPort().get(),
serverPreferences.proxySocksHost().get(), proxySocksHost = serverPreferences.proxySocksHost().get(),
serverPreferences.proxySocksPort().get(), proxySocksPort = serverPreferences.proxySocksPort().get(),
serverPreferences.auth().get(), auth = serverPreferences.auth().get(),
serverPreferences.authUsername().get(), authUsername = serverPreferences.authUsername().get(),
serverPreferences.authPassword().get(), authPassword = serverPreferences.authPassword().get(),
json, uiAccessTokenPreference = userPreferences.uiAccessToken(),
simpleSessionPreference = userPreferences.simpleSession(),
userRefreshUI = userRefreshUI,
json = json,
)
)
@OptIn(DelicateCoroutinesApi::class)
fun httpClientNoAuth(
serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
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,
) )
) )

View File

@@ -15,4 +15,6 @@ enum class Auth {
NONE, NONE,
BASIC, BASIC,
DIGEST, DIGEST,
SIMPLE,
UI,
} }

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,6 @@
package ca.gosyer.jui.domain.user.model
data class LoginData(
val refreshToken: String,
val accessToken: String,
)

View File

@@ -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<String> = preferenceStore.getString("ui_refresh_token", "")
fun uiAccessToken(): Preference<String> = preferenceStore.getString("ui_refresh_token", "")
fun simpleSession(): Preference<String> = preferenceStore.getString("simple_session", "")
}

View File

@@ -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<LoginData>
fun refreshUI(refreshToken: String): Flow<String>
fun loginSimple(username: String, password: String): Flow<String>
}

View File

@@ -9,6 +9,7 @@ package ca.gosyer.jui.domain.server.service
import ca.gosyer.jui.core.prefs.Preference import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore import ca.gosyer.jui.core.prefs.PreferenceStore
import ca.gosyer.jui.domain.server.service.host.ServerHostPreference import ca.gosyer.jui.domain.server.service.host.ServerHostPreference
import ca.gosyer.jui.domain.settings.model.AuthMode
actual class ServerHostPreferences actual constructor( actual class ServerHostPreferences actual constructor(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
@@ -17,46 +18,48 @@ actual class ServerHostPreferences actual constructor(
// IP // IP
private val ip = ServerHostPreference.IP(preferenceStore) private val ip = ServerHostPreference.IP(preferenceStore)
fun ip(): Preference<String> = ip.preference() fun ip(): Preference<String> = ip.preference()
private val port = ServerHostPreference.Port(preferenceStore) private val port = ServerHostPreference.Port(preferenceStore)
fun port(): Preference<Int> = port.preference() fun port(): Preference<Int> = port.preference()
// Root // Root
private val rootPath = ServerHostPreference.RootPath(preferenceStore) private val rootPath = ServerHostPreference.RootPath(preferenceStore)
fun rootPath(): Preference<String> = rootPath.preference() fun rootPath(): Preference<String> = rootPath.preference()
// Downloader // Downloader
private val downloadPath = ServerHostPreference.DownloadPath(preferenceStore) private val downloadPath = ServerHostPreference.DownloadPath(preferenceStore)
fun downloadPath(): Preference<String> = downloadPath.preference() fun downloadPath(): Preference<String> = downloadPath.preference()
// Backup // Backup
private val backupPath = ServerHostPreference.BackupPath(preferenceStore) private val backupPath = ServerHostPreference.BackupPath(preferenceStore)
fun backupPath(): Preference<String> = backupPath.preference() fun backupPath(): Preference<String> = backupPath.preference()
// LocalSource // LocalSource
private val localSourcePath = ServerHostPreference.LocalSourcePath(preferenceStore) private val localSourcePath = ServerHostPreference.LocalSourcePath(preferenceStore)
fun localSourcePath(): Preference<String> = localSourcePath.preference() fun localSourcePath(): Preference<String> = localSourcePath.preference()
// Authentication // Authentication
private val basicAuthEnabled = ServerHostPreference.BasicAuthEnabled(preferenceStore) private val basicAuthEnabled = ServerHostPreference.BasicAuthEnabled(preferenceStore)
@Deprecated("")
fun basicAuthEnabled(): Preference<Boolean> = basicAuthEnabled.preference() fun basicAuthEnabled(): Preference<Boolean> = basicAuthEnabled.preference()
private val basicAuthUsername = ServerHostPreference.BasicAuthUsername(preferenceStore) private val basicAuthUsername = ServerHostPreference.BasicAuthUsername(preferenceStore)
@Deprecated("")
fun basicAuthUsername(): Preference<String> = basicAuthUsername.preference() fun basicAuthUsername(): Preference<String> = basicAuthUsername.preference()
private val basicAuthPassword = ServerHostPreference.BasicAuthPassword(preferenceStore) private val basicAuthPassword = ServerHostPreference.BasicAuthPassword(preferenceStore)
@Deprecated("")
fun basicAuthPassword(): Preference<String> = basicAuthPassword.preference() fun basicAuthPassword(): Preference<String> = basicAuthPassword.preference()
// Authentication
private val authMode = ServerHostPreference.AuthMode(preferenceStore)
fun authMode(): Preference<AuthMode> = authMode.preference()
private val authUsername = ServerHostPreference.AuthUsername(preferenceStore)
fun authUsername(): Preference<String> = authUsername.preference()
private val authPassword = ServerHostPreference.AuthPassword(preferenceStore)
fun authPassword(): Preference<String> = authPassword.preference()
fun properties(): Array<String> = fun properties(): Array<String> =
listOf( listOf(
ip, ip,
@@ -65,9 +68,9 @@ actual class ServerHostPreferences actual constructor(
downloadPath, downloadPath,
backupPath, backupPath,
localSourcePath, localSourcePath,
basicAuthEnabled, authMode,
basicAuthUsername, authUsername,
basicAuthPassword, authPassword,
).mapNotNull { ).mapNotNull {
it.getProperty() it.getProperty()
}.plus( }.plus(

View File

@@ -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.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore 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<T : Any> { sealed class ServerHostPreference<T : Any> {
protected abstract val propertyName: String protected abstract val propertyName: String
@@ -63,6 +65,16 @@ sealed class ServerHostPreference<T : Any> {
override fun preference(): Preference<Boolean> = preferenceStore.getBoolean(propertyName, defaultValue) override fun preference(): Preference<Boolean> = preferenceStore.getBoolean(propertyName, defaultValue)
} }
sealed class ObjectServerHostPreference<T : Any>(
override val preferenceStore: PreferenceStore,
override val propertyName: String,
override val defaultValue: T,
override val serverValue: T = defaultValue,
private val getObject: (String, T) -> Preference<T>,
) : ServerHostPreference<T>() {
override fun preference(): Preference<T> = getObject(propertyName, defaultValue)
}
// Root // Root
class RootPath( class RootPath(
preferenceStore: PreferenceStore, preferenceStore: PreferenceStore,
@@ -116,27 +128,43 @@ sealed class ServerHostPreference<T : Any> {
) )
// Authentication // Authentication
@Deprecated("UseAuthMode")
class BasicAuthEnabled( class BasicAuthEnabled(
preferenceStore: PreferenceStore, preferenceStore: PreferenceStore,
) : BooleanServerHostPreference( ) : BooleanServerHostPreference(preferenceStore, "basicAuthEnabled", false)
@Deprecated("UseAuthUsername")
class BasicAuthUsername(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(preferenceStore, "basicAuthUsername", "")
@Deprecated("UseAuthPassword")
class BasicAuthPassword(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(preferenceStore, "basicAuthPassword", "")
class AuthMode(
preferenceStore: PreferenceStore,
) : ObjectServerHostPreference<ServerAuthMode>(
preferenceStore, preferenceStore,
"basicAuthEnabled", "authMode",
false, ServerAuthMode.NONE,
getObject = { propertyName, default ->
preferenceStore.getEnum(propertyName, default)
}
) )
class BasicAuthUsername( class AuthUsername(
preferenceStore: PreferenceStore, preferenceStore: PreferenceStore,
) : StringServerHostPreference( ) : StringServerHostPreference(
preferenceStore, preferenceStore,
"basicAuthUsername", "authUsername",
"", "",
) )
class BasicAuthPassword( class AuthPassword(
preferenceStore: PreferenceStore, preferenceStore: PreferenceStore,
) : StringServerHostPreference( ) : StringServerHostPreference(
preferenceStore, preferenceStore,
"basicAuthPassword", "authPassword",
"", "",
) )
} }

View File

@@ -319,9 +319,10 @@
<string name="host_webui_sub">Whether the server\'s default WebUI is enabled, makes you able to use Suwayomi in your browser</string> <string name="host_webui_sub">Whether the server\'s default WebUI is enabled, makes you able to use Suwayomi in your browser</string>
<string name="host_open_in_browser">Open Server WebUI on startup</string> <string name="host_open_in_browser">Open Server WebUI on startup</string>
<string name="host_open_in_browser_sub">Open the WebUI inside your browser on server startup. Requires the WebUI be enabled</string> <string name="host_open_in_browser_sub">Open the WebUI inside your browser on server startup. Requires the WebUI be enabled</string>
<string name="host_basic_auth_sub">Use basic auth to protect your library, requires username and password</string> <string name="host_auth">Auth mode</string>
<string name="host_basic_auth_username">Basic auth username</string> <string name="host_auth_sub">Use authentication to protect your library, requires username and password</string>
<string name="host_basic_auth_password">Basic auth password</string> <string name="host_auth_username">Auth username</string>
<string name="host_auth_password">Auth password</string>
<string name="server_url">Server URL</string> <string name="server_url">Server URL</string>
<string name="server_port">Server PORT</string> <string name="server_port">Server PORT</string>
<string name="server_path_prefix">Server Path Prefix</string> <string name="server_path_prefix">Server Path Prefix</string>
@@ -338,6 +339,10 @@
<string name="no_auth">No auth</string> <string name="no_auth">No auth</string>
<string name="basic_auth">Basic auth</string> <string name="basic_auth">Basic auth</string>
<string name="digest_auth">Digest auth</string> <string name="digest_auth">Digest auth</string>
<string name="simple_auth">Simple auth</string>
<string name="ui_login">UI login</string>
<string name="login">Login</string>
<string name="logout">Logout</string>
<string name="auth_username">Auth username</string> <string name="auth_username">Auth username</string>
<string name="auth_password">Auth password</string> <string name="auth_password">Auth password</string>
<string name="server_settings_sub">The below settings configure the connected Suwayomi-Server</string> <string name="server_settings_sub">The below settings configure the connected Suwayomi-Server</string>

View File

@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.Icon import androidx.compose.material.Icon
@@ -35,6 +36,8 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Info 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ca.gosyer.jui.core.lang.launchIO 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.server.service.ServerPreferences
import ca.gosyer.jui.domain.settings.interactor.GetSettings import ca.gosyer.jui.domain.settings.interactor.GetSettings
import ca.gosyer.jui.domain.settings.interactor.SetSettings 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.SetSettingsInput
import ca.gosyer.jui.domain.settings.model.Settings 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.i18n.MR
import ca.gosyer.jui.ui.base.dialog.getMaterialDialogProperties import ca.gosyer.jui.ui.base.dialog.getMaterialDialogProperties
import ca.gosyer.jui.ui.base.navigation.Toolbar import ca.gosyer.jui.ui.base.navigation.Toolbar
@@ -95,10 +108,13 @@ import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.format.char import kotlinx.datetime.format.char
@@ -127,8 +143,12 @@ class SettingsServerScreen : Screen {
authChoices = connectionVM.getAuthChoices(), authChoices = connectionVM.getAuthChoices(),
authUsername = connectionVM.authUsername, authUsername = connectionVM.authUsername,
authPassword = connectionVM.authPassword, authPassword = connectionVM.authPassword,
authUILoggedIn = connectionVM.authUILoggedIn.collectAsState().value,
authSimpleLoggedIn = connectionVM.authSimpleLoggedIn.collectAsState().value,
serverSettings = connectionVM.serverSettings.collectAsState().value, serverSettings = connectionVM.serverSettings.collectAsState().value,
hosted = connectionVM.host.collectAsState().value, hosted = connectionVM.host.collectAsState().value,
onLogin = connectionVM::onLogin,
onLogout = connectionVM::onLogout,
) )
} }
} }
@@ -194,18 +214,18 @@ class ServerSettings(
getInput = { SetSettingsInput(backupTime = it) }, getInput = { SetSettingsInput(backupTime = it) },
) )
// val basicAuthEnabled = getServerFlow( val authMode = getServerFlow(
// getSetting = { it.basicAuthEnabled }, getSetting = { it.authMode },
// getInput = { SetSettingsInput(basicAuthEnabled = it) }, getInput = { SetSettingsInput(authMode = it) },
// ) )
// val basicAuthPassword = getServerFlow( val authPassword = getServerFlow(
// getSetting = { it.basicAuthPassword }, getSetting = { it.authPassword },
// getInput = { SetSettingsInput(basicAuthPassword = it) }, getInput = { SetSettingsInput(authPassword = it) },
// ) )
// val basicAuthUsername = getServerFlow( val authUsername = getServerFlow(
// getSetting = { it.basicAuthUsername }, getSetting = { it.authUsername },
// getInput = { SetSettingsInput(basicAuthUsername = it) }, getInput = { SetSettingsInput(authUsername = it) },
// ) )
val debugLogsEnabled = getServerFlow( val debugLogsEnabled = getServerFlow(
getSetting = { it.debugLogsEnabled }, getSetting = { it.debugLogsEnabled },
getInput = { SetSettingsInput(debugLogsEnabled = it) }, getInput = { SetSettingsInput(debugLogsEnabled = it) },
@@ -370,6 +390,10 @@ class SettingsServerViewModel(
private val setSettings: SetSettings, private val setSettings: SetSettings,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
serverHostPreferences: ServerHostPreferences, serverHostPreferences: ServerHostPreferences,
userPreferences: UserPreferences,
private val userLoginSimple: UserLoginSimple,
private val userLoginUi: UserLoginUI,
private val userLogout: UserLogout,
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) { ) : ViewModel(contextWrapper) {
val serverUrl = serverPreferences.server().asStateIn(scope) val serverUrl = serverPreferences.server().asStateIn(scope)
@@ -394,6 +418,12 @@ class SettingsServerViewModel(
val socksPort = serverPreferences.proxySocksPort().asStringStateIn(scope) val socksPort = serverPreferences.proxySocksPort().asStringStateIn(scope)
val auth = serverPreferences.auth().asStateIn(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 @Composable
fun getAuthChoices(): ImmutableMap<Auth, String> = fun getAuthChoices(): ImmutableMap<Auth, String> =
@@ -401,11 +431,31 @@ class SettingsServerViewModel(
Auth.NONE to stringResource(MR.strings.no_auth), Auth.NONE to stringResource(MR.strings.no_auth),
Auth.BASIC to stringResource(MR.strings.basic_auth), Auth.BASIC to stringResource(MR.strings.basic_auth),
Auth.DIGEST to stringResource(MR.strings.digest_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 authUsername = serverPreferences.authUsername().asStateIn(scope)
val authPassword = serverPreferences.authPassword().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<ServerSettings?>(null) private val _serverSettings = MutableStateFlow<ServerSettings?>(null)
val serverSettings = _serverSettings.asStateFlow() val serverSettings = _serverSettings.asStateFlow()
@@ -443,8 +493,12 @@ fun SettingsServerScreenContent(
authChoices: ImmutableMap<Auth, String>, authChoices: ImmutableMap<Auth, String>,
authUsername: PreferenceMutableStateFlow<String>, authUsername: PreferenceMutableStateFlow<String>,
authPassword: PreferenceMutableStateFlow<String>, authPassword: PreferenceMutableStateFlow<String>,
authUILoggedIn: Boolean,
authSimpleLoggedIn: Boolean,
hosted: Boolean, hosted: Boolean,
serverSettings: ServerSettings?, serverSettings: ServerSettings?,
onLogin: (String, String) -> Unit,
onLogout: () -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
@@ -540,7 +594,10 @@ fun SettingsServerScreenContent(
item { item {
ChoicePreference(auth, authChoices, stringResource(MR.strings.authentication)) ChoicePreference(auth, authChoices, stringResource(MR.strings.authentication))
} }
if (authValue != Auth.NONE) {
when (authValue) {
Auth.NONE -> Unit
Auth.BASIC, Auth.DIGEST, Auth.UI, Auth.SIMPLE -> {
item { item {
EditTextPreference(authUsername, stringResource(MR.strings.auth_username)) EditTextPreference(authUsername, stringResource(MR.strings.auth_username))
} }
@@ -552,6 +609,80 @@ fun SettingsServerScreenContent(
) )
} }
} }
}
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 { item {
Divider() Divider()
} }
@@ -814,32 +945,41 @@ fun LazyListScope.ServerSettingsItems(
) )
} }
// item { item {
// SwitchPreference( ChoicePreference(
// preference = serverSettings.basicAuthEnabled, preference = serverSettings.authMode,
// title = stringResource(MR.strings.basic_auth), title = stringResource(MR.strings.host_auth),
// subtitle = stringResource(MR.strings.host_basic_auth_sub), subtitle = stringResource(MR.strings.host_auth_sub),
// enabled = !hosted, choices = (AuthMode.entries - AuthMode.UNKNOWN__).associateWith {
// ) when (it) {
// } AuthMode.NONE -> stringResource(MR.strings.no_auth)
// AuthMode.BASIC_AUTH -> stringResource(MR.strings.basic_auth)
// item { AuthMode.SIMPLE_LOGIN -> stringResource(MR.strings.simple_auth)
// val basicAuthEnabledValue by serverSettings.basicAuthEnabled.collectAsState() AuthMode.UI_LOGIN -> stringResource(MR.strings.ui_login)
// EditTextPreference( AuthMode.UNKNOWN__ -> ""
// preference = serverSettings.basicAuthUsername, }
// title = stringResource(MR.strings.host_basic_auth_username), }.toImmutableMap(),
// enabled = basicAuthEnabledValue && !hosted, enabled = !hosted,
// ) )
// } }
// item {
// val basicAuthEnabledValue by serverSettings.basicAuthEnabled.collectAsState() item {
// EditTextPreference( val authModeValue by serverSettings.authMode.collectAsState()
// preference = serverSettings.basicAuthPassword, EditTextPreference(
// title = stringResource(MR.strings.host_basic_auth_password), preference = serverSettings.authUsername,
// visualTransformation = PasswordVisualTransformation(), title = stringResource(MR.strings.host_auth_username),
// enabled = basicAuthEnabledValue && !hosted, 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 { item {
SwitchPreference( SwitchPreference(
preference = serverSettings.flareSolverrEnabled, preference = serverSettings.flareSolverrEnabled,

View File

@@ -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.ServerHostPreferences
import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.server.service.ServerService 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.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.EditTextPreference
import ca.gosyer.jui.ui.base.prefs.PreferenceRow import ca.gosyer.jui.ui.base.prefs.PreferenceRow
import ca.gosyer.jui.ui.base.prefs.SwitchPreference 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.resources.stringResource
import ca.gosyer.jui.uicore.vm.ContextWrapper import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.ViewModel import ca.gosyer.jui.uicore.vm.ViewModel
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -39,7 +42,7 @@ import me.tatarka.inject.annotations.Inject
actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostViewModel): LazyListScope.() -> Unit { actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostViewModel): LazyListScope.() -> Unit {
val serverVm = viewModel() val serverVm = viewModel()
val hostValue by serverVm.host.collectAsState() val hostValue by serverVm.host.collectAsState()
val basicAuthEnabledValue by serverVm.basicAuthEnabled.collectAsState() val authMode by serverVm.hostAuthMode.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -50,7 +53,7 @@ actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostVie
return { return {
ServerHostItems( ServerHostItems(
hostValue = hostValue, hostValue = hostValue,
basicAuthEnabledValue = basicAuthEnabledValue, authEnabledValue = authMode != AuthMode.NONE,
serverSettingChanged = serverVm::serverSettingChanged, serverSettingChanged = serverVm::serverSettingChanged,
host = serverVm.host, host = serverVm.host,
ip = serverVm.ip, ip = serverVm.ip,
@@ -59,9 +62,9 @@ actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostVie
downloadPath = serverVm.downloadPath, downloadPath = serverVm.downloadPath,
backupPath = serverVm.backupPath, backupPath = serverVm.backupPath,
localSourcePath = serverVm.localSourcePath, localSourcePath = serverVm.localSourcePath,
basicAuthEnabled = serverVm.basicAuthEnabled, authMode = serverVm.hostAuthMode,
basicAuthUsername = serverVm.basicAuthUsername, authUsername = serverVm.hostAuthUsername,
basicAuthPassword = serverVm.basicAuthPassword, authPassword = serverVm.hostAuthPassword,
) )
} }
} }
@@ -92,9 +95,9 @@ actual class SettingsServerHostViewModel(
val localSourcePath = serverHostPreferences.localSourcePath().asStateIn(scope) val localSourcePath = serverHostPreferences.localSourcePath().asStateIn(scope)
// Authentication // Authentication
val basicAuthEnabled = serverHostPreferences.basicAuthEnabled().asStateIn(scope) val hostAuthMode = serverHostPreferences.authMode().asStateIn(scope)
val basicAuthUsername = serverHostPreferences.basicAuthUsername().asStateIn(scope) val hostAuthUsername = serverHostPreferences.authUsername().asStateIn(scope)
val basicAuthPassword = serverHostPreferences.basicAuthPassword().asStateIn(scope) val hostAuthPassword = serverHostPreferences.authPassword().asStateIn(scope)
private val _serverSettingChanged = MutableStateFlow(false) private val _serverSettingChanged = MutableStateFlow(false)
val serverSettingChanged = _serverSettingChanged.asStateFlow() val serverSettingChanged = _serverSettingChanged.asStateFlow()
@@ -115,16 +118,18 @@ actual class SettingsServerHostViewModel(
val authPassword = serverPreferences.authPassword().asStateIn(scope) val authPassword = serverPreferences.authPassword().asStateIn(scope)
init { init {
combine(host, basicAuthEnabled, basicAuthUsername, basicAuthPassword) { host, enabled, username, password -> combine(host, hostAuthMode, hostAuthUsername, hostAuthPassword) { host, mode, username, password ->
if (host) { if (host) {
if (enabled) { when (mode) {
AuthMode.NONE -> auth.value = Auth.NONE
AuthMode.BASIC_AUTH -> {
auth.value = Auth.BASIC auth.value = Auth.BASIC
authUsername.value = username authUsername.value = username
authPassword.value = password authPassword.value = password
} else { }
auth.value = Auth.NONE AuthMode.SIMPLE_LOGIN -> auth.value = Auth.SIMPLE
authUsername.value = "" AuthMode.UI_LOGIN -> auth.value = Auth.UI
authPassword.value = "" AuthMode.UNKNOWN__ -> Unit
} }
} }
}.launchIn(scope) }.launchIn(scope)
@@ -133,7 +138,7 @@ actual class SettingsServerHostViewModel(
fun LazyListScope.ServerHostItems( fun LazyListScope.ServerHostItems(
hostValue: Boolean, hostValue: Boolean,
basicAuthEnabledValue: Boolean, authEnabledValue: Boolean,
serverSettingChanged: () -> Unit, serverSettingChanged: () -> Unit,
host: MutableStateFlow<Boolean>, host: MutableStateFlow<Boolean>,
ip: MutableStateFlow<String>, ip: MutableStateFlow<String>,
@@ -142,9 +147,9 @@ fun LazyListScope.ServerHostItems(
downloadPath: MutableStateFlow<String>, downloadPath: MutableStateFlow<String>,
backupPath: MutableStateFlow<String>, backupPath: MutableStateFlow<String>,
localSourcePath: MutableStateFlow<String>, localSourcePath: MutableStateFlow<String>,
basicAuthEnabled: MutableStateFlow<Boolean>, authMode: MutableStateFlow<AuthMode>,
basicAuthUsername: MutableStateFlow<String>, authUsername: MutableStateFlow<String>,
basicAuthPassword: MutableStateFlow<String>, authPassword: MutableStateFlow<String>,
) { ) {
item { item {
SwitchPreference(preference = host, title = stringResource(MR.strings.host_server)) SwitchPreference(preference = host, title = stringResource(MR.strings.host_server))
@@ -260,28 +265,37 @@ fun LazyListScope.ServerHostItems(
) )
} }
item { item {
SwitchPreference( ChoicePreference(
preference = basicAuthEnabled, preference = authMode,
title = stringResource(MR.strings.basic_auth), title = stringResource(MR.strings.host_auth),
subtitle = stringResource(MR.strings.host_basic_auth_sub), 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, changeListener = serverSettingChanged,
) )
} }
item { item {
EditTextPreference( EditTextPreference(
preference = basicAuthUsername, preference = authUsername,
title = stringResource(MR.strings.host_basic_auth_username), title = stringResource(MR.strings.host_auth_username),
changeListener = serverSettingChanged, changeListener = serverSettingChanged,
enabled = basicAuthEnabledValue, enabled = authEnabledValue,
) )
} }
item { item {
EditTextPreference( EditTextPreference(
preference = basicAuthPassword, preference = authPassword,
title = stringResource(MR.strings.host_basic_auth_password), title = stringResource(MR.strings.host_auth_password),
changeListener = serverSettingChanged, changeListener = serverSettingChanged,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
enabled = basicAuthEnabledValue, enabled = authEnabledValue,
) )
} }
} }