Account management with OIDC: split account and session management. #1303
This commit is contained in:
parent
aa22e731f9
commit
51e663ffdc
11 changed files with 108 additions and 18 deletions
|
|
@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -67,9 +68,17 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||||
plugins<Callback>().forEach { it.onOpenAbout() }
|
plugins<Callback>().forEach { it.onOpenAbout() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onManageAccountClicked(activity: Activity, accountManagementUrl: String?) {
|
private fun onManageAccountClicked(
|
||||||
accountManagementUrl?.let {
|
activity: Activity,
|
||||||
activity.openUrlInChromeCustomTab(null, false, it)
|
url: String?,
|
||||||
|
isDark: Boolean,
|
||||||
|
) {
|
||||||
|
url?.let {
|
||||||
|
activity.openUrlInChromeCustomTab(
|
||||||
|
null,
|
||||||
|
darkTheme = isDark,
|
||||||
|
url = it
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,6 +90,7 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
val activity = LocalContext.current as Activity
|
val activity = LocalContext.current as Activity
|
||||||
|
val isDark = ElementTheme.isLightTheme.not()
|
||||||
PreferencesRootView(
|
PreferencesRootView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -91,7 +101,7 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||||
onVerifyClicked = this::onVerifyClicked,
|
onVerifyClicked = this::onVerifyClicked,
|
||||||
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
|
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
|
||||||
onSuccessLogout = { onSuccessLogout(activity, it) },
|
onSuccessLogout = { onSuccessLogout(activity, it) },
|
||||||
onManageAccountClicked = { onManageAccountClicked(activity, state.accountManagementUrl) },
|
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
|
||||||
onOpenNotificationSettings = this::onOpenNotificationSettings
|
onOpenNotificationSettings = this::onOpenNotificationSettings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsS
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.user.getCurrentUser
|
import io.element.android.libraries.matrix.api.user.getCurrentUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
|
@ -74,9 +75,12 @@ class PreferencesRootPresenter @Inject constructor(
|
||||||
val accountManagementUrl: MutableState<String?> = remember {
|
val accountManagementUrl: MutableState<String?> = remember {
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
val devicesManagementUrl: MutableState<String?> = remember {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
initAccountManagementUrl(accountManagementUrl)
|
initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
val logoutState = logoutPresenter.present()
|
val logoutState = logoutPresenter.present()
|
||||||
|
|
@ -87,6 +91,7 @@ class PreferencesRootPresenter @Inject constructor(
|
||||||
version = versionFormatter.get(),
|
version = versionFormatter.get(),
|
||||||
showCompleteVerification = showCompleteVerification,
|
showCompleteVerification = showCompleteVerification,
|
||||||
accountManagementUrl = accountManagementUrl.value,
|
accountManagementUrl = accountManagementUrl.value,
|
||||||
|
devicesManagementUrl = devicesManagementUrl.value,
|
||||||
showAnalyticsSettings = hasAnalyticsProviders,
|
showAnalyticsSettings = hasAnalyticsProviders,
|
||||||
showDeveloperSettings = showDeveloperSettings,
|
showDeveloperSettings = showDeveloperSettings,
|
||||||
showNotificationSettings = showNotificationSettings.value,
|
showNotificationSettings = showNotificationSettings.value,
|
||||||
|
|
@ -98,7 +103,11 @@ class PreferencesRootPresenter @Inject constructor(
|
||||||
matrixUser.value = matrixClient.getCurrentUser()
|
matrixUser.value = matrixClient.getCurrentUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.initAccountManagementUrl(accountManagementUrl: MutableState<String?>) = launch {
|
private fun CoroutineScope.initAccountManagementUrl(
|
||||||
accountManagementUrl.value = matrixClient.getAccountManagementUrl().getOrNull()
|
accountManagementUrl: MutableState<String?>,
|
||||||
|
devicesManagementUrl: MutableState<String?>,
|
||||||
|
) = launch {
|
||||||
|
accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull()
|
||||||
|
devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList).getOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ data class PreferencesRootState(
|
||||||
val version: String,
|
val version: String,
|
||||||
val showCompleteVerification: Boolean,
|
val showCompleteVerification: Boolean,
|
||||||
val accountManagementUrl: String?,
|
val accountManagementUrl: String?,
|
||||||
|
val devicesManagementUrl: String?,
|
||||||
val showAnalyticsSettings: Boolean,
|
val showAnalyticsSettings: Boolean,
|
||||||
val showDeveloperSettings: Boolean,
|
val showDeveloperSettings: Boolean,
|
||||||
val showNotificationSettings: Boolean,
|
val showNotificationSettings: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ fun aPreferencesRootState() = PreferencesRootState(
|
||||||
version = "Version 1.1 (1)",
|
version = "Version 1.1 (1)",
|
||||||
showCompleteVerification = true,
|
showCompleteVerification = true,
|
||||||
accountManagementUrl = "aUrl",
|
accountManagementUrl = "aUrl",
|
||||||
|
devicesManagementUrl = "anOtherUrl",
|
||||||
showAnalyticsSettings = true,
|
showAnalyticsSettings = true,
|
||||||
showDeveloperSettings = true,
|
showDeveloperSettings = true,
|
||||||
showNotificationSettings = true,
|
showNotificationSettings = true,
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ import androidx.compose.material.icons.outlined.BugReport
|
||||||
import androidx.compose.material.icons.outlined.DeveloperMode
|
import androidx.compose.material.icons.outlined.DeveloperMode
|
||||||
import androidx.compose.material.icons.outlined.Help
|
import androidx.compose.material.icons.outlined.Help
|
||||||
import androidx.compose.material.icons.outlined.InsertChart
|
import androidx.compose.material.icons.outlined.InsertChart
|
||||||
import androidx.compose.material.icons.outlined.ManageAccounts
|
|
||||||
import androidx.compose.material.icons.outlined.Notifications
|
import androidx.compose.material.icons.outlined.Notifications
|
||||||
|
import androidx.compose.material.icons.outlined.OpenInNew
|
||||||
import androidx.compose.material.icons.outlined.VerifiedUser
|
import androidx.compose.material.icons.outlined.VerifiedUser
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -53,7 +53,7 @@ fun PreferencesRootView(
|
||||||
state: PreferencesRootState,
|
state: PreferencesRootState,
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
onVerifyClicked: () -> Unit,
|
onVerifyClicked: () -> Unit,
|
||||||
onManageAccountClicked: () -> Unit,
|
onManageAccountClicked: (url: String) -> Unit,
|
||||||
onOpenAnalytics: () -> Unit,
|
onOpenAnalytics: () -> Unit,
|
||||||
onOpenRageShake: () -> Unit,
|
onOpenRageShake: () -> Unit,
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
|
|
@ -82,10 +82,11 @@ fun PreferencesRootView(
|
||||||
}
|
}
|
||||||
if (state.accountManagementUrl != null) {
|
if (state.accountManagementUrl != null) {
|
||||||
PreferenceText(
|
PreferenceText(
|
||||||
title = stringResource(id = CommonStrings.screen_settings_oidc_account),
|
title = stringResource(id = CommonStrings.action_manage_account),
|
||||||
icon = Icons.Outlined.ManageAccounts,
|
icon = Icons.Outlined.OpenInNew,
|
||||||
onClick = onManageAccountClicked,
|
onClick = { onManageAccountClicked(state.accountManagementUrl) },
|
||||||
)
|
)
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
if (state.showAnalyticsSettings) {
|
if (state.showAnalyticsSettings) {
|
||||||
PreferenceText(
|
PreferenceText(
|
||||||
|
|
@ -94,7 +95,7 @@ fun PreferencesRootView(
|
||||||
onClick = onOpenAnalytics,
|
onClick = onOpenAnalytics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if(state.showNotificationSettings) {
|
if (state.showNotificationSettings) {
|
||||||
PreferenceText(
|
PreferenceText(
|
||||||
title = stringResource(id = CommonStrings.screen_notification_settings_title),
|
title = stringResource(id = CommonStrings.screen_notification_settings_title),
|
||||||
icon = Icons.Outlined.Notifications,
|
icon = Icons.Outlined.Notifications,
|
||||||
|
|
@ -111,10 +112,19 @@ fun PreferencesRootView(
|
||||||
icon = Icons.Outlined.Help,
|
icon = Icons.Outlined.Help,
|
||||||
onClick = onOpenAbout,
|
onClick = onOpenAbout,
|
||||||
)
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
if (state.devicesManagementUrl != null) {
|
||||||
|
PreferenceText(
|
||||||
|
title = stringResource(id = CommonStrings.action_manage_devices),
|
||||||
|
icon = Icons.Outlined.OpenInNew,
|
||||||
|
onClick = { onManageAccountClicked(state.devicesManagementUrl) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
if (state.showDeveloperSettings) {
|
if (state.showDeveloperSettings) {
|
||||||
DeveloperPreferencesView(onOpenDeveloperSettings)
|
DeveloperPreferencesView(onOpenDeveloperSettings)
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
|
||||||
LogoutPreferenceView(
|
LogoutPreferenceView(
|
||||||
state = state.logoutState,
|
state = state.logoutState,
|
||||||
onSuccessLogout = onSuccessLogout,
|
onSuccessLogout = onSuccessLogout,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class PreferencesRootPresenterTest {
|
||||||
assertThat(loadedState.showDeveloperSettings).isEqualTo(true)
|
assertThat(loadedState.showDeveloperSettings).isEqualTo(true)
|
||||||
assertThat(loadedState.showAnalyticsSettings).isEqualTo(false)
|
assertThat(loadedState.showAnalyticsSettings).isEqualTo(false)
|
||||||
assertThat(loadedState.accountManagementUrl).isNull()
|
assertThat(loadedState.accountManagementUrl).isNull()
|
||||||
|
assertThat(loadedState.devicesManagementUrl).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
|
@ -66,7 +67,7 @@ interface MatrixClient : Closeable {
|
||||||
suspend fun logout(): String?
|
suspend fun logout(): String?
|
||||||
suspend fun loadUserDisplayName(): Result<String>
|
suspend fun loadUserDisplayName(): Result<String>
|
||||||
suspend fun loadUserAvatarURLString(): Result<String?>
|
suspend fun loadUserAvatarURLString(): Result<String?>
|
||||||
suspend fun getAccountManagementUrl(): Result<String?>
|
suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?>
|
||||||
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
|
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
|
||||||
fun roomMembershipObserver(): RoomMembershipObserver
|
fun roomMembershipObserver(): RoomMembershipObserver
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.oidc
|
||||||
|
|
||||||
|
sealed interface AccountManagementAction {
|
||||||
|
data object Profile : AccountManagementAction
|
||||||
|
data object SessionsList : AccountManagementAction
|
||||||
|
data class SessionView(val deviceId: String) : AccountManagementAction
|
||||||
|
data class SessionEnd(val deviceId: String) : AccountManagementAction
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
|
@ -44,6 +45,7 @@ import io.element.android.libraries.matrix.impl.mapper.toSessionData
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||||
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
||||||
|
import io.element.android.libraries.matrix.impl.oidc.toRustAction
|
||||||
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
||||||
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
|
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
|
||||||
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
||||||
|
|
@ -326,9 +328,10 @@ class RustMatrixClient constructor(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAccountManagementUrl(): Result<String?> = withContext(sessionDispatcher) {
|
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> = withContext(sessionDispatcher) {
|
||||||
|
val rustAction = action?.toRustAction()
|
||||||
runCatching {
|
runCatching {
|
||||||
client.accountUrl()
|
client.accountUrl(rustAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.oidc
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
|
import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction
|
||||||
|
|
||||||
|
fun AccountManagementAction.toRustAction(): RustAccountManagementAction {
|
||||||
|
return when (this) {
|
||||||
|
AccountManagementAction.Profile -> RustAccountManagementAction.Profile
|
||||||
|
is AccountManagementAction.SessionEnd -> RustAccountManagementAction.SessionEnd(deviceId)
|
||||||
|
is AccountManagementAction.SessionView -> RustAccountManagementAction.SessionView(deviceId)
|
||||||
|
AccountManagementAction.SessionsList -> RustAccountManagementAction.SessionsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
|
@ -129,7 +130,7 @@ class FakeMatrixClient(
|
||||||
return userAvatarURLString
|
return userAvatarURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAccountManagementUrl(): Result<String?> {
|
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> {
|
||||||
return accountManagementUrlString
|
return accountManagementUrlString
|
||||||
}
|
}
|
||||||
override suspend fun uploadMedia(
|
override suspend fun uploadMedia(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue