Skip to content

Commit 1f5997e

Browse files
authored
SOLR-17659: Add basic auth support in new UI (#3435)
* Add basic auth support * Rename "unauthenticated" component resources Rename "unauthenticated" component resources to "authentication" * Fix tab navigation and improve user feedback and context * Add tests and implement missing logic * Add logout functionality and merge main component instantiation functions
1 parent 199eed9 commit 1f5997e

46 files changed

Lines changed: 2217 additions & 203 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ kotlinx-browser = "0.3"
150150
kotlinx-coroutines = "1.10.1"
151151
kotlinx-datetime = "0.6.2"
152152
kotlinx-serialization = "1.8.0"
153-
ktor = "3.1.0"
153+
ktor = "3.2.2"
154154
langchain4j = "0.35.0"
155155
# @keep Link checker version used in ref-guide
156156
link-checker = "1.4.2"
@@ -438,6 +438,7 @@ kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serializa
438438
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
439439
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
440440
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
441+
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
441442
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
442443
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
443444
ktor-client-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

solr/ui/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ kotlin {
9191
implementation(libs.mvikotlin.main)
9292
implementation(libs.mvikotlin.logging)
9393

94+
implementation(libs.ktor.client.auth)
9495
implementation(libs.ktor.client.core)
9596
implementation(libs.ktor.client.cio)
9697
implementation(libs.ktor.client.contentNegotiation)

solr/ui/gradle.lockfile

Lines changed: 90 additions & 81 deletions
Large diffs are not rendered by default.

solr/ui/src/commonMain/composeResources/values/strings.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,24 @@
1919
<resources>
2020
<!-- Actions (action) -->
2121
<string name="action_connect">Connect</string>
22+
<string name="action_go_back">Go Back</string>
2223
<string name="action_logout">Logout</string>
24+
<string name="action_sign_in_with_credentials">Sign In with Credentials</string>
2325

2426
<!-- Content Descriptions (cd) -->
27+
<string name="cd_back_navigation">Back Navigation</string>
2528
<string name="cd_solr_logo">Solr Logo</string>
2629

2730
<!-- Descriptions (desc) -->
2831
<string name="desc_to_get_started">To get started, please provide a Solr host URL:</string>
32+
<string name="desc_solr_instance_with_auth">The Solr instance %1$s has enabled authentication.</string>
33+
<string name="desc_sign_in_with_credentials_to_realm">To authenticate you may use your credentials for the realm %1$s.</string>
2934

3035
<!-- Errors (error) -->
3136
<string name="error_invalid_url">The provided URL is invalid.</string>
3237
<string name="error_solr_host_not_found">Solr host could not be found.</string>
3338
<string name="error_unknown">An unknown error occurred.</string>
39+
<string name="error_invalid_credentials">Invalid credentials. Please try again.</string>
3440

3541
<!-- Navigation (nav) -->
3642
<string name="nav_cluster">Cluster</string>
@@ -49,8 +55,10 @@
4955

5056
<!-- Titles (title) -->
5157
<string name="title_welcome_to_solr">Welcome to Solr Admin UI</string>
58+
<string name="title_sign_in_to_solr">Sign In to Solr</string>
5259

5360
<!-- General Text -->
61+
<string name="authenticating">Authenticating...</string>
5462
<string name="community">Community</string>
5563
<string name="documentation">Documentation</string>
5664
<string name="irc">IRC</string>
@@ -60,5 +68,9 @@
6068
<string name="support">Support</string>
6169
<string name="connecting">Connecting...</string>
6270

71+
<!-- Input Labels -->
72+
<string name="label_username">Username</string>
73+
<string name="label_password">Password</string>
74+
6375
<!-- Uncategorized -->
6476
</resources>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.solr.ui.components.auth
19+
20+
import com.arkivanov.decompose.router.slot.ChildSlot
21+
import com.arkivanov.decompose.value.Value
22+
import io.ktor.http.Url
23+
import kotlinx.coroutines.flow.StateFlow
24+
import kotlinx.serialization.Serializable
25+
import org.apache.solr.ui.domain.AuthMethod
26+
import org.apache.solr.ui.domain.AuthOption
27+
import org.jetbrains.compose.resources.StringResource
28+
29+
/**
30+
* The authentication component takes care of the authentication processes. This typically includes
31+
* user authentication with credentials, tokens or certificates.
32+
*/
33+
interface AuthenticationComponent {
34+
35+
/**
36+
* Component state flow.
37+
*/
38+
val model: StateFlow<Model>
39+
40+
/**
41+
* A child slot that holds the [BasicAuthComponent] for user authentication with credentials
42+
* (basic auth) if it is supported.
43+
*/
44+
val basicAuthSlot: Value<ChildSlot<BasicAuthConfiguration, BasicAuthComponent>>
45+
46+
@Serializable
47+
data class BasicAuthConfiguration(val method: AuthMethod.BasicAuthMethod = AuthMethod.BasicAuthMethod())
48+
49+
/**
50+
* Aborts the authentication attempt.
51+
*/
52+
fun onAbort()
53+
54+
/**
55+
* Model data class that represents the component state.
56+
*
57+
* @property url URL of the Solr instance the user is trying to connect.
58+
* @property methods List of authentication methods to render.
59+
* @property isAuthenticating Whether a connection is currently established.
60+
* @property error The error that may have occurred.
61+
*/
62+
data class Model(
63+
val url: String = "",
64+
val methods: List<AuthMethod> = emptyList(),
65+
val isAuthenticating: Boolean = false,
66+
val error: StringResource? = null,
67+
)
68+
69+
sealed interface Output {
70+
71+
/**
72+
* Emitted when the user successfully authenticated against the Solr instance.
73+
*
74+
* @property option The final authentication option that succeeded.
75+
*/
76+
data class OnAuthenticated(val option: AuthOption) : Output
77+
78+
/**
79+
* Emitted when the user aborts the authentication flow.
80+
*/
81+
data object OnAbort : Output
82+
}
83+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.solr.ui.components.auth
19+
20+
import kotlinx.coroutines.flow.StateFlow
21+
import org.apache.solr.ui.domain.AuthMethod
22+
23+
/**
24+
* Component interface that is handling basic authentication with username and password.
25+
*/
26+
interface BasicAuthComponent {
27+
28+
val model: StateFlow<Model>
29+
30+
/**
31+
* Function that handles username changes.
32+
*/
33+
fun onChangeUsername(username: String)
34+
35+
/**
36+
* Function that handles password changes.
37+
*/
38+
fun onChangePassword(password: String)
39+
40+
/**
41+
* Tries to authenticate the user with the given username and password.
42+
*/
43+
fun onAuthenticate()
44+
45+
/**
46+
* Data class model that represents the [BasicAuthComponent] state.
47+
*
48+
* @property realm The basic auth realm the user is supposed to provide the credentials for.
49+
* @property username The username that is currently typed in.
50+
* @property password The password that is currently typed in.
51+
* @property hasError Whether the current input has an error. This is a simplified variant that
52+
* does not distinguish input fields.
53+
*/
54+
data class Model(
55+
val realm: String = "",
56+
val username: String = "",
57+
val password: String = "",
58+
val hasError: Boolean = false,
59+
)
60+
61+
sealed interface Output {
62+
63+
/**
64+
* Output that is emitted when a connection process is started.
65+
*/
66+
data object Authenticating : Output
67+
68+
/**
69+
* Output that is emitted when the user successfully connected to the server
70+
* with the credentials.
71+
*
72+
* The username and password are passed to the parent to configure a client
73+
* with basic auth and pass it to other instances for further use.
74+
*
75+
* @property method The method that was used for authentication.
76+
* @property username The username that was used for authentication.
77+
* @property password The password that was used for authentication.
78+
*/
79+
data class Authenticated(
80+
val method: AuthMethod.BasicAuthMethod,
81+
val username: String,
82+
val password: String,
83+
) : Output
84+
85+
/**
86+
* Output that is emitted when an authentication error occurs, for example, in case
87+
* of invalid credentials.
88+
*
89+
* @property error The error that was thrown during a connection establishment.
90+
*/
91+
data class AuthenticationFailed(val error: Throwable) : Output
92+
93+
/**
94+
* Output that allows resetting any error reported via [AuthenticationFailed].
95+
*/
96+
data object ErrorReset : Output
97+
}
98+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.solr.ui.components.auth.integration
19+
20+
import com.arkivanov.decompose.router.slot.ChildSlot
21+
import com.arkivanov.decompose.router.slot.SlotNavigation
22+
import com.arkivanov.decompose.router.slot.activate
23+
import com.arkivanov.decompose.router.slot.childSlot
24+
import com.arkivanov.decompose.value.Value
25+
import com.arkivanov.essenty.lifecycle.doOnCreate
26+
import com.arkivanov.mvikotlin.core.instancekeeper.getStore
27+
import com.arkivanov.mvikotlin.core.store.StoreFactory
28+
import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow
29+
import io.ktor.client.HttpClient
30+
import io.ktor.http.Url
31+
import kotlinx.coroutines.ExperimentalCoroutinesApi
32+
import kotlinx.coroutines.SupervisorJob
33+
import org.apache.solr.ui.components.auth.AuthenticationComponent
34+
import org.apache.solr.ui.components.auth.AuthenticationComponent.BasicAuthConfiguration
35+
import org.apache.solr.ui.components.auth.AuthenticationComponent.Output
36+
import org.apache.solr.ui.components.auth.AuthenticationComponent.Output.OnAuthenticated
37+
import org.apache.solr.ui.components.auth.BasicAuthComponent
38+
import org.apache.solr.ui.components.auth.store.AuthenticationStore.Intent
39+
import org.apache.solr.ui.components.auth.store.AuthenticationStore.Intent.FailAuthentication
40+
import org.apache.solr.ui.components.auth.store.AuthenticationStore.Intent.ResetError
41+
import org.apache.solr.ui.components.auth.store.AuthenticationStoreProvider
42+
import org.apache.solr.ui.domain.AuthMethod
43+
import org.apache.solr.ui.domain.AuthOption
44+
import org.apache.solr.ui.utils.AppComponentContext
45+
import org.apache.solr.ui.utils.coroutineScope
46+
import org.apache.solr.ui.utils.map
47+
48+
/**
49+
* Default implementation of the [AuthenticationComponent].
50+
*
51+
* @param componentContext Application component context.
52+
* @param storeFactory Store factory to pass to the store provider.
53+
* @param httpClient HTTP client to use for API interactions. The client has to be pre-configured
54+
* with a base URL pointing to a Solr instance.
55+
* @param methods A list of authentication methods that are supported by the Solr instance the
56+
* [httpClient] is pointing to.
57+
* @property output Output handler that is used to send outputs from this component.
58+
*/
59+
class DefaultAuthenticationComponent(
60+
componentContext: AppComponentContext,
61+
storeFactory: StoreFactory,
62+
httpClient: HttpClient,
63+
private val url: Url,
64+
methods: List<AuthMethod>,
65+
private val output: (Output) -> Unit,
66+
) : AuthenticationComponent,
67+
AppComponentContext by componentContext {
68+
69+
private val mainScope = coroutineScope(SupervisorJob() + mainContext)
70+
private val ioScope = coroutineScope(SupervisorJob() + ioContext)
71+
72+
private val store = instanceKeeper.getStore {
73+
AuthenticationStoreProvider(
74+
storeFactory = storeFactory,
75+
mainContext = mainScope.coroutineContext,
76+
ioContext = ioScope.coroutineContext,
77+
url = url,
78+
methods = methods,
79+
).provide()
80+
}
81+
82+
private val basicAuthNavigation = SlotNavigation<BasicAuthConfiguration>()
83+
84+
@OptIn(ExperimentalCoroutinesApi::class)
85+
override val model = store.stateFlow.map(mainScope, authStateToModel)
86+
87+
override val basicAuthSlot: Value<ChildSlot<BasicAuthConfiguration, BasicAuthComponent>> =
88+
childSlot(
89+
source = basicAuthNavigation,
90+
serializer = BasicAuthConfiguration.serializer(),
91+
handleBackButton = true,
92+
) { config, childComponentContext ->
93+
DefaultBasicAuthComponent(
94+
componentContext = childComponentContext,
95+
storeFactory = storeFactory,
96+
httpClient = httpClient,
97+
method = config.method,
98+
output = ::basicAuthOutput,
99+
)
100+
}
101+
102+
init {
103+
lifecycle.doOnCreate {
104+
methods.forEach { method ->
105+
when (method) {
106+
is AuthMethod.BasicAuthMethod ->
107+
basicAuthNavigation.activate(configuration = BasicAuthConfiguration(method))
108+
}
109+
}
110+
}
111+
}
112+
113+
/**
114+
* Output handler function that handles the outputs from [BasicAuthComponent]s.
115+
*
116+
* @param output The output of the component to handle
117+
*/
118+
private fun basicAuthOutput(output: BasicAuthComponent.Output): Unit = when (output) {
119+
is BasicAuthComponent.Output.Authenticated -> output(
120+
OnAuthenticated(
121+
option = AuthOption.BasicAuthOption(
122+
url = url,
123+
method = output.method,
124+
username = output.username,
125+
password = output.password,
126+
),
127+
),
128+
)
129+
130+
is BasicAuthComponent.Output.Authenticating -> store.accept(Intent.StartAuthenticating)
131+
is BasicAuthComponent.Output.AuthenticationFailed ->
132+
store.accept(FailAuthentication(output.error))
133+
134+
BasicAuthComponent.Output.ErrorReset ->
135+
store.accept(ResetError)
136+
}
137+
138+
override fun onAbort() = output(Output.OnAbort)
139+
}

0 commit comments

Comments
 (0)