diff --git a/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt b/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt index eb37dc8..b3acbdc 100644 --- a/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt +++ b/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt @@ -25,11 +25,15 @@ import com.garfiec.librechat.core.data.repository.MemoryRepository import com.garfiec.librechat.core.data.repository.MessageRepository import com.garfiec.librechat.core.data.repository.PresetRepository import com.garfiec.librechat.core.data.repository.PromptRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.SpeechRepository import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.data.repository.UserRepository +import com.garfiec.librechat.core.data.util.PermissionGate +import com.garfiec.librechat.core.data.util.SessionTask +import com.garfiec.librechat.core.data.util.SessionTaskRunner import com.garfiec.librechat.core.network.api.AgentsApi import com.garfiec.librechat.core.network.api.ApiKeysApi import com.garfiec.librechat.core.network.api.AuthApi @@ -155,11 +159,15 @@ class KoinGraphVerificationTest { MessageRepository::class, PresetRepository::class, PromptRepository::class, + RoleRepository::class, SearchRepository::class, ShareRepository::class, SpeechRepository::class, TagRepository::class, UserRepository::class, + PermissionGate::class, + SessionTask::class, + SessionTaskRunner::class, // feature:auth platform provides OAuthLauncher::class, // feature:files platform provides diff --git a/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt b/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt index 3341d6a..654100c 100644 --- a/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt +++ b/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt @@ -9,10 +9,12 @@ import com.garfiec.librechat.core.common.di.KoinQualifiers import com.garfiec.librechat.core.data.datastore.TokenDataStore import com.garfiec.librechat.core.data.db.LibreChatDatabase import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SessionCacheCleaner import com.garfiec.librechat.core.network.client.SecureTokenStorage import com.garfiec.librechat.core.network.client.TokenManager import io.ktor.client.HttpClient +import kotlinx.coroutines.CoroutineScope import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.dsl.binds @@ -41,5 +43,11 @@ actual val dataPlatformModule: Module = module { } binds arrayOf(TokenManager::class, SecureTokenStorage::class) // --- Session Cache Cleaner --- - single { CommonSessionCacheCleaner(androidContext().cacheDir.absolutePath) } + single { + CommonSessionCacheCleaner( + cacheRoot = androidContext().cacheDir.absolutePath, + roleRepository = get(), + applicationScope = get(KoinQualifiers.ApplicationScope), + ) + } } diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt index a24a99f..175217f 100644 --- a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt @@ -20,6 +20,7 @@ import com.garfiec.librechat.core.network.api.MemoriesApi import com.garfiec.librechat.core.network.api.MessagesApi import com.garfiec.librechat.core.network.api.PresetsApi import com.garfiec.librechat.core.network.api.PromptsApi +import com.garfiec.librechat.core.network.api.RolesApi import com.garfiec.librechat.core.network.api.SearchApi import com.garfiec.librechat.core.network.api.ShareApi import com.garfiec.librechat.core.network.api.SpeechApi @@ -28,6 +29,7 @@ import com.garfiec.librechat.core.network.api.UserApi import com.garfiec.librechat.core.network.sse.SseClient import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.json.Json import org.junit.Test import org.koin.test.verify.verify @@ -43,6 +45,7 @@ class DataModuleVerificationTest { HttpClient::class, Json::class, CoroutineDispatcher::class, + CoroutineScope::class, ConnectivityObserver::class, SseClient::class, AuthApi::class, @@ -55,6 +58,7 @@ class DataModuleVerificationTest { AgentsApi::class, PresetsApi::class, PromptsApi::class, + RolesApi::class, TagsApi::class, ShareApi::class, ConfigApi::class, diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/RoleRepositoryImplTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/RoleRepositoryImplTest.kt new file mode 100644 index 0000000..cd150c7 --- /dev/null +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/RoleRepositoryImplTest.kt @@ -0,0 +1,186 @@ +package com.garfiec.librechat.core.data.repository + +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.data.datastore.RoleCacheDataStore +import com.garfiec.librechat.core.model.User +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import com.garfiec.librechat.core.network.api.RolesApi +import com.google.common.truth.Truth.assertThat +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class RoleRepositoryImplTest { + + private val rolesApi = mockk(relaxed = true) + private val userRepository = mockk(relaxed = true) + private val cacheDataStore = mockk(relaxed = true) + + private val adminRole = UserRolePermissions( + name = "ADMIN", + permissions = mapOf( + "PROMPTS" to mapOf("USE" to false), + "BOOKMARKS" to mapOf("USE" to true), + ), + ) + + private val userRole = UserRolePermissions( + name = "USER", + permissions = mapOf("PROMPTS" to mapOf("USE" to true)), + ) + + private fun TestScope.newRepo(): RoleRepositoryImpl { + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + return RoleRepositoryImpl( + rolesApi = rolesApi, + userRepository = userRepository, + cacheDataStore = cacheDataStore, + applicationScope = scope, + ) + } + + @Test + fun `fetchUserRole calls rolesApi with user's role name`() = runTest { + coEvery { cacheDataStore.load() } returns null + coEvery { cacheDataStore.save(any()) } just Runs + coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN")) + coEvery { rolesApi.getRole("ADMIN") } returns adminRole + + val repo = newRepo() + advanceUntilIdle() + val result = repo.fetchUserRole() + + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat((result as Result.Success).data).isEqualTo(adminRole) + coVerify(exactly = 1) { rolesApi.getRole("ADMIN") } + } + + @Test + fun `fetchUserRole uses role name not a hardcoded string`() = runTest { + coEvery { cacheDataStore.load() } returns null + coEvery { cacheDataStore.save(any()) } just Runs + coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "USER")) + coEvery { rolesApi.getRole("USER") } returns userRole + + val repo = newRepo() + advanceUntilIdle() + repo.fetchUserRole() + + coVerify(exactly = 1) { rolesApi.getRole("USER") } + } + + @Test + fun `fetchUserRole populates StateFlow and saves to cache on success`() = runTest { + coEvery { cacheDataStore.load() } returns null + coEvery { cacheDataStore.save(adminRole) } just Runs + coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN")) + coEvery { rolesApi.getRole("ADMIN") } returns adminRole + + val repo = newRepo() + advanceUntilIdle() + repo.fetchUserRole() + + assertThat(repo.userPermissions.value).isEqualTo(adminRole) + coVerify(exactly = 1) { cacheDataStore.save(adminRole) } + } + + @Test + fun `fetchUserRole returns cached value on network failure when cache populated`() = runTest { + // Prime the cache so StateFlow has a value before the failing fetch. + coEvery { cacheDataStore.load() } returns adminRole + coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN")) + coEvery { rolesApi.getRole("ADMIN") } throws IOException("offline") + + val repo = newRepo() + advanceUntilIdle() // let the cache-prime launch complete + val result = repo.fetchUserRole() + + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat((result as Result.Success).data).isEqualTo(adminRole) + assertThat(repo.userPermissions.value).isEqualTo(adminRole) + } + + @Test + fun `fetchUserRole returns Error when network fails and no cached value`() = runTest { + coEvery { cacheDataStore.load() } returns null + coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN")) + coEvery { rolesApi.getRole("ADMIN") } throws IOException("offline") + + val repo = newRepo() + advanceUntilIdle() + val result = repo.fetchUserRole() + + assertThat(result).isInstanceOf(Result.Error::class.java) + assertThat(repo.userPermissions.value).isNull() + } + + @Test + fun `fetchUserRole returns Error when userRepository fails`() = runTest { + coEvery { cacheDataStore.load() } returns null + coEvery { userRepository.getUser() } returns Result.Error(RuntimeException("no user"), "no user") + + val repo = newRepo() + advanceUntilIdle() + val result = repo.fetchUserRole() + + assertThat(result).isInstanceOf(Result.Error::class.java) + coVerify(exactly = 0) { rolesApi.getRole(any()) } + } + + @Test + fun `clear wipes in-memory StateFlow and DataStore`() = runTest { + coEvery { cacheDataStore.load() } returns adminRole + coEvery { cacheDataStore.clear() } just Runs + + val repo = newRepo() + advanceUntilIdle() + assertThat(repo.userPermissions.value).isEqualTo(adminRole) + + repo.clear() + + assertThat(repo.userPermissions.value).isNull() + coVerify(exactly = 1) { cacheDataStore.clear() } + } + + @Test + fun `custom role 404-style error keeps prior cached value intact`() = runTest { + // Cache holds the pre-change custom role permissions. + val customRole = UserRolePermissions( + name = "CUSTOM_ROLE", + permissions = mapOf(PermissionType.AGENTS.serverKey to mapOf(Permission.USE.serverKey to true)), + ) + coEvery { cacheDataStore.load() } returns customRole + coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "CUSTOM_ROLE")) + coEvery { rolesApi.getRole("CUSTOM_ROLE") } throws IOException("404 Not Found") + + val repo = newRepo() + advanceUntilIdle() + val result = repo.fetchUserRole() + + // Repository falls back to cached value, StateFlow not nulled out. + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat(repo.userPermissions.value).isEqualTo(customRole) + } + + @Test + fun `cache prime populates StateFlow from DataStore at construction`() = runTest { + coEvery { cacheDataStore.load() } returns adminRole + val repo = newRepo() + advanceUntilIdle() // StandardTestDispatcher needs this to run the init-block launch + + assertThat(repo.userPermissions.value).isEqualTo(adminRole) + } +} diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/util/PermissionGateTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/util/PermissionGateTest.kt new file mode 100644 index 0000000..d44e1d6 --- /dev/null +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/util/PermissionGateTest.kt @@ -0,0 +1,51 @@ +package com.garfiec.librechat.core.data.util + +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PermissionGateTest { + + private fun fakeRepository(flow: StateFlow): RoleRepository = + object : RoleRepository { + override val userPermissions: StateFlow = flow + override suspend fun fetchUserRole() = error("not needed in gate tests") + override suspend fun clear() { /* no-op */ } + } + + private val adminRole = UserRolePermissions( + name = "ADMIN", + permissions = mapOf( + "PROMPTS" to mapOf("USE" to false, "CREATE" to true), + "AGENTS" to mapOf("USE" to false), + ), + ) + + @Test + fun `awaitRole returns null when role never loads within 5s`() = runTest { + val flow = MutableStateFlow(null) + val gate = PermissionGate(fakeRepository(flow)) + + val deferred = async { gate.awaitRole() } + advanceTimeBy(PermissionGate.ROLE_LOAD_TIMEOUT_MS + 100) + + assertThat(deferred.await()).isNull() + } + + @Test + fun `awaitRole returns loaded role immediately after emission`() = runTest { + val flow = MutableStateFlow(adminRole) + val gate = PermissionGate(fakeRepository(flow)) + + val role = gate.awaitRole() + assertThat(role).isEqualTo(adminRole) + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/RoleCacheDataStore.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/RoleCacheDataStore.kt new file mode 100644 index 0000000..479ade7 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/RoleCacheDataStore.kt @@ -0,0 +1,48 @@ +package com.garfiec.librechat.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json + +class RoleCacheDataStore( + private val dataStore: DataStore, + private val json: Json, +) { + + suspend fun save(role: UserRolePermissions) { + try { + val serialized = json.encodeToString(UserRolePermissions.serializer(), role) + dataStore.edit { prefs -> prefs[KEY_ROLE] = serialized } + } catch (e: Exception) { + Logger.w(e) { "Failed to cache user role permissions" } + } + } + + suspend fun load(): UserRolePermissions? { + return try { + val prefs = dataStore.data.first() + val serialized = prefs[KEY_ROLE] ?: return null + json.decodeFromString(UserRolePermissions.serializer(), serialized) + } catch (e: Exception) { + Logger.w(e) { "Failed to load cached user role permissions" } + null + } + } + + suspend fun clear() { + try { + dataStore.edit { prefs -> prefs.remove(KEY_ROLE) } + } catch (e: Exception) { + Logger.w(e) { "Failed to clear cached user role permissions" } + } + } + + private companion object { + val KEY_ROLE = stringPreferencesKey("cached_user_role_permissions") + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt index 32d8e8e..b3c6959 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt @@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.di import com.garfiec.librechat.core.common.di.KoinQualifiers import com.garfiec.librechat.core.data.datastore.ConfigCacheDataStore +import com.garfiec.librechat.core.data.datastore.RoleCacheDataStore import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.datastore.SettingsDataStore import com.garfiec.librechat.core.data.datastore.ThemeDataStore @@ -38,6 +39,8 @@ import com.garfiec.librechat.core.data.repository.PresetRepository import com.garfiec.librechat.core.data.repository.PresetRepositoryImpl import com.garfiec.librechat.core.data.repository.PromptRepository import com.garfiec.librechat.core.data.repository.PromptRepositoryImpl +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.data.repository.RoleRepositoryImpl import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.SearchRepositoryImpl import com.garfiec.librechat.core.data.repository.ShareRepository @@ -48,7 +51,14 @@ import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.data.repository.TagRepositoryImpl import com.garfiec.librechat.core.data.repository.UserRepository import com.garfiec.librechat.core.data.repository.UserRepositoryImpl +import com.garfiec.librechat.core.data.util.PermissionGate +import com.garfiec.librechat.core.data.util.RefreshTagsSessionTask +import com.garfiec.librechat.core.data.util.RoleFetchSessionTask +import com.garfiec.librechat.core.data.util.SessionTask +import com.garfiec.librechat.core.data.util.SessionTaskRunner +import com.garfiec.librechat.core.data.util.SyncFavoritesSessionTask import com.garfiec.librechat.core.network.client.ServerUrlProvider +import kotlinx.coroutines.CoroutineScope import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind @@ -81,6 +91,7 @@ val dataModule = module { } bind ServerUrlProvider::class singleOf(::ThemeDataStore) singleOf(::ConfigCacheDataStore) + singleOf(::RoleCacheDataStore) singleOf(::SettingsDataStore) // --- Repositories (special wiring) --- @@ -91,6 +102,33 @@ val dataModule = module { userApi = get(), tokenManager = get(), sessionCacheCleaner = get(), + sessionTaskRunner = get(), + ) + } + + single { + RoleRepositoryImpl( + rolesApi = get(), + userRepository = get(), + cacheDataStore = get(), + applicationScope = get(KoinQualifiers.ApplicationScope), + ) + } + + singleOf(::PermissionGate) + + // --- Session tasks --- + // Work that runs whenever the app transitions into an authenticated session. + // Fires from two places only: AuthRepositoryImpl's login/OAuth/2FA success paths + // and NavHostViewModel.init when a session is restored at cold-start. + // Add new tasks here. + singleOf(::RoleFetchSessionTask) bind SessionTask::class + singleOf(::RefreshTagsSessionTask) bind SessionTask::class + singleOf(::SyncFavoritesSessionTask) bind SessionTask::class + single { + SessionTaskRunner( + tasks = getAll(), + applicationScope = get(KoinQualifiers.ApplicationScope), ) } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/AuthRepositoryImpl.kt index 3cb973c..67f63ee 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/AuthRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.repository import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.common.result.safeApiCall +import com.garfiec.librechat.core.data.util.SessionTaskRunner import com.garfiec.librechat.core.model.LoginOutcome import com.garfiec.librechat.core.model.User import com.garfiec.librechat.core.model.response.TwoFactorSetupResponse @@ -14,8 +15,18 @@ class AuthRepositoryImpl( private val userApi: UserApi, private val tokenManager: TokenManager, private val sessionCacheCleaner: SessionCacheCleaner, + private val sessionTaskRunner: SessionTaskRunner, ) : AuthRepository { + /** + * Fires session tasks when [this] represents an authenticated success. Callers pass + * [isAuthenticated] to discriminate: for OAuth/2FA any `Result.Success` is a real + * sign-in, but `login()`'s `LoginOutcome.TwoFactorRequired` is a pending state (user + * isn't authenticated yet) so tasks defer to `verifyTwoFactor()`. + */ + private fun Result.fireSessionTasksIfLoggedIn(isAuthenticated: (T) -> Boolean = { true }): Result = + also { if (it is Result.Success && isAuthenticated(it.data)) sessionTaskRunner.runAll() } + override suspend fun login(email: String, password: String): Result { return safeApiCall { val result = authApi.login(email, password) @@ -34,7 +45,7 @@ class AuthRepositoryImpl( ?: throw IllegalStateException("Login succeeded but user was null in response"), ) } - } + }.fireSessionTasksIfLoggedIn { outcome -> outcome is LoginOutcome.Success } } override suspend fun loginWithOAuthToken(refreshToken: String): Result { @@ -50,7 +61,7 @@ class AuthRepositoryImpl( ) // Fetch the user profile userApi.getUser() - } + }.fireSessionTasksIfLoggedIn() } override suspend fun verifyTwoFactor(tempToken: String, code: String): Result { @@ -61,7 +72,7 @@ class AuthRepositoryImpl( refreshToken = result.refreshToken ?: "", ) result.response.user ?: throw IllegalStateException("No user in 2FA response") - } + }.fireSessionTasksIfLoggedIn() } override suspend fun register( diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/CacheDirectory.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/CacheDirectory.kt index d58cdc2..381541e 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/CacheDirectory.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/CacheDirectory.kt @@ -1,6 +1,8 @@ package com.garfiec.librechat.core.data.repository import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Subdirectories under the platform cache root that are cleared on logout. @@ -24,10 +26,13 @@ internal expect fun deleteDirectoryRecursively(path: String) /** * Common implementation of [SessionCacheCleaner] that iterates [CACHE_SUBDIRECTORIES] - * and deletes each from the provided [cacheRoot] directory. + * and deletes each from the provided [cacheRoot] directory. Also clears the cached + * role permissions so permission gates don't leak across user sessions. */ class CommonSessionCacheCleaner( private val cacheRoot: String, + private val roleRepository: RoleRepository, + private val applicationScope: CoroutineScope, ) : SessionCacheCleaner { override fun clearSessionCaches() { try { @@ -37,5 +42,8 @@ class CommonSessionCacheCleaner( } catch (e: Exception) { Logger.w(e) { "Failed to clear session caches on logout" } } + // roleRepository.clear() is a suspend function; dispatch it into the app scope so + // logout remains synchronous and can't be stalled by DataStore write latency. + applicationScope.launch { roleRepository.clear() } } } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/RoleRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/RoleRepository.kt new file mode 100644 index 0000000..4a40c78 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/RoleRepository.kt @@ -0,0 +1,23 @@ +package com.garfiec.librechat.core.data.repository + +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import kotlinx.coroutines.flow.StateFlow + +interface RoleRepository { + /** + * Latest permissions for the authenticated user's role, or null if not yet loaded. + * Emits null briefly at startup before the DataStore cache prime completes. + */ + val userPermissions: StateFlow + + /** + * Fetches `/api/roles/{currentUser.role}`, updates the StateFlow + DataStore on success. + * On network failure with a cached value present, returns Result.Success(cached) so + * gates can keep working offline. On network failure with no cache, returns Result.Error. + */ + suspend fun fetchUserRole(): Result + + /** Wipes in-memory StateFlow and DataStore cache. Called on logout. */ + suspend fun clear() +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/RoleRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/RoleRepositoryImpl.kt new file mode 100644 index 0000000..e8b89d9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/RoleRepositoryImpl.kt @@ -0,0 +1,61 @@ +package com.garfiec.librechat.core.data.repository + +import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.data.datastore.RoleCacheDataStore +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import com.garfiec.librechat.core.network.api.RolesApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class RoleRepositoryImpl( + private val rolesApi: RolesApi, + private val userRepository: UserRepository, + private val cacheDataStore: RoleCacheDataStore, + applicationScope: CoroutineScope, +) : RoleRepository { + + private val _userPermissions = MutableStateFlow(null) + override val userPermissions: StateFlow = _userPermissions.asStateFlow() + + init { + // Async DataStore prime — never runBlocking. The StateFlow briefly emits null + // then populates from disk; PermissionGate's permissive default covers the flash. + applicationScope.launch { + cacheDataStore.load()?.let { _userPermissions.value = it } + } + } + + override suspend fun fetchUserRole(): Result { + val userResult = userRepository.getUser() + val user = when (userResult) { + is Result.Success -> userResult.data + is Result.Error -> return Result.Error(userResult.exception, "No current user") + is Result.Loading -> return Result.Error(message = "User load still in progress") + } + + return try { + val role = rolesApi.getRole(user.role) + _userPermissions.value = role + cacheDataStore.save(role) + Result.Success(role) + } catch (e: Exception) { + val cached = _userPermissions.value + if (cached != null) { + Logger.w(e) { "fetchUserRole failed, keeping cached role '${cached.name}'" } + Result.Success(cached) + } else { + Logger.w(e) { "fetchUserRole failed with no cached value" } + Result.Error(e, e.message ?: "Failed to load role permissions") + } + } + } + + override suspend fun clear() { + _userPermissions.value = null + cacheDataStore.clear() + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/PermissionGate.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/PermissionGate.kt new file mode 100644 index 0000000..e269279 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/PermissionGate.kt @@ -0,0 +1,22 @@ +package com.garfiec.librechat.core.data.util + +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +class PermissionGate(private val roleRepository: RoleRepository) { + /** + * Awaits the role to load (up to [ROLE_LOAD_TIMEOUT_MS]) and returns it. + * Null return = timeout or fetch-failure; callers should treat it as permissive. + */ + suspend fun awaitRole(): UserRolePermissions? = + withTimeoutOrNull(ROLE_LOAD_TIMEOUT_MS) { + roleRepository.userPermissions.filterNotNull().first() + } + + companion object { + const val ROLE_LOAD_TIMEOUT_MS = 5_000L + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/RefreshTagsSessionTask.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/RefreshTagsSessionTask.kt new file mode 100644 index 0000000..81c4efc --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/RefreshTagsSessionTask.kt @@ -0,0 +1,15 @@ +package com.garfiec.librechat.core.data.util + +import com.garfiec.librechat.core.data.repository.TagRepository + +/** + * Refreshes conversation tags from the server when a session starts so the drawer has + * up-to-date tag counts. Fires in parallel with other session tasks. + */ +class RefreshTagsSessionTask( + private val tagRepository: TagRepository, +) : SessionTask { + override suspend fun run() { + tagRepository.refreshTags() + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/RoleFetchSessionTask.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/RoleFetchSessionTask.kt new file mode 100644 index 0000000..cfa4640 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/RoleFetchSessionTask.kt @@ -0,0 +1,16 @@ +package com.garfiec.librechat.core.data.util + +import com.garfiec.librechat.core.data.repository.RoleRepository + +/** + * Loads the authenticated user's role permissions when a session starts. Without this, + * permission gates stay permissive-null across app restarts until a gated screen + * happens to trigger a user fetch. + */ +class RoleFetchSessionTask( + private val roleRepository: RoleRepository, +) : SessionTask { + override suspend fun run() { + roleRepository.fetchUserRole() + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/SessionTask.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/SessionTask.kt new file mode 100644 index 0000000..0a47989 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/SessionTask.kt @@ -0,0 +1,37 @@ +package com.garfiec.librechat.core.data.util + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Work that runs whenever the app transitions into an authenticated session — either a + * fresh login / OAuth / 2FA success, or a cold-start where a valid session is restored. + * Implementations should be fire-and-forget; the caller does not block on completion. + */ +interface SessionTask { + suspend fun run() +} + +/** + * Invokes each registered [SessionTask] on [applicationScope] so task work outlives the + * caller's scope (short-lived auth VM or the one-shot NavHostViewModel init block). + * Individual task failures are logged and swallowed so one bad task doesn't poison the + * rest of the session-start path. + */ +class SessionTaskRunner( + private val tasks: List, + private val applicationScope: CoroutineScope, +) { + fun runAll() { + tasks.forEach { task -> + applicationScope.launch { + try { + task.run() + } catch (e: Exception) { + Logger.w(e) { "SessionTask ${task::class.simpleName} failed" } + } + } + } + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/SyncFavoritesSessionTask.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/SyncFavoritesSessionTask.kt new file mode 100644 index 0000000..658ebcb --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/SyncFavoritesSessionTask.kt @@ -0,0 +1,15 @@ +package com.garfiec.librechat.core.data.util + +import com.garfiec.librechat.core.data.repository.ConversationRepository + +/** + * Pulls the latest favorite-conversation markers from the server when a session starts + * so the drawer's favorites section is correct before the user interacts with it. + */ +class SyncFavoritesSessionTask( + private val conversationRepository: ConversationRepository, +) : SessionTask { + override suspend fun run() { + conversationRepository.syncFavoritesFromServer() + } +} diff --git a/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt b/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt index 261f0cb..cdf6ce4 100644 --- a/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt +++ b/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt @@ -10,11 +10,13 @@ import com.garfiec.librechat.core.data.datastore.IosTokenDataStore import com.garfiec.librechat.core.data.datastore.ServerUrlKeychainFallback import com.garfiec.librechat.core.data.db.LibreChatDatabase import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SessionCacheCleaner import com.garfiec.librechat.core.network.client.SecureTokenStorage import com.garfiec.librechat.core.network.client.TokenManager import io.ktor.client.HttpClient import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import okio.Path.Companion.toPath @@ -69,6 +71,10 @@ actual val dataPlatformModule: Module = module { NSUserDomainMask, true, ).firstOrNull() as? String ?: error("Unable to resolve NSCachesDirectory") - CommonSessionCacheCleaner(cachePath) + CommonSessionCacheCleaner( + cacheRoot = cachePath, + roleRepository = get(), + applicationScope = get(KoinQualifiers.ApplicationScope), + ) } } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/Permission.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/Permission.kt new file mode 100644 index 0000000..0c15d72 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/Permission.kt @@ -0,0 +1,7 @@ +package com.garfiec.librechat.core.model.permissions + +enum class Permission(val serverKey: String) { + USE("USE"), + CREATE("CREATE"), + SHARE("SHARE"), +} diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/PermissionType.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/PermissionType.kt new file mode 100644 index 0000000..f8e1ca4 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/PermissionType.kt @@ -0,0 +1,16 @@ +package com.garfiec.librechat.core.model.permissions + +enum class PermissionType(val serverKey: String) { + PROMPTS("PROMPTS"), + AGENTS("AGENTS"), + MEMORIES("MEMORIES"), + MCP_SERVERS("MCP_SERVERS"), + BOOKMARKS("BOOKMARKS"), + WEB_SEARCH("WEB_SEARCH"), + RUN_CODE("RUN_CODE"), + FILE_SEARCH("FILE_SEARCH"), + TEMPORARY_CHAT("TEMPORARY_CHAT"), + MULTI_CONVO("MULTI_CONVO"), + MARKETPLACE("MARKETPLACE"), + REMOTE_AGENTS("REMOTE_AGENTS"), +} diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/UserRolePermissions.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/UserRolePermissions.kt new file mode 100644 index 0000000..cea0660 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/permissions/UserRolePermissions.kt @@ -0,0 +1,38 @@ +package com.garfiec.librechat.core.model.permissions + +import kotlinx.serialization.Serializable + +@Serializable +data class UserRolePermissions( + val name: String, + val description: String? = null, + val permissions: Map> = emptyMap(), +) { + /** + * Returns true if the permission is explicitly granted OR unknown. + * + * Permissive-default is intentional for forward compatibility: if a future server + * adds a new PermissionType the client doesn't know about, or omits a field on an + * older build, the client optimistically shows the UI. The server's middleware still + * enforces via 403 — the client gate is for UX (hiding dead-end buttons), not security. + * + * This diverges from the PWA's `useHasAccess` (which returns false on unknown). The + * rationale is mobile-specific: mobile apps are long-lived, so an old-client-on-new-server + * scenario is more likely than on web. Permissive default avoids breaking existing users + * when the server adds new permission types between releases. + */ + fun hasAccess(type: PermissionType, permission: Permission): Boolean { + val perms = permissions[type.serverKey] ?: return true + val value = perms[permission.serverKey] ?: return true + return value + } +} + +/** + * Permissive-default variant of [UserRolePermissions.hasAccess] that treats a null role + * (still loading, fetch failed, timed out) as permissive. Replaces the `role?.hasAccess(...) ?: true` + * idiom so the permissive contract lives in one place instead of being open-coded at every + * role-flag mapping site across the VMs. + */ +fun UserRolePermissions?.hasAccessOrPermissive(type: PermissionType, permission: Permission): Boolean = + this?.hasAccess(type, permission) ?: true diff --git a/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/permissions/UserRolePermissionsTest.kt b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/permissions/UserRolePermissionsTest.kt new file mode 100644 index 0000000..1765340 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/garfiec/librechat/core/model/permissions/UserRolePermissionsTest.kt @@ -0,0 +1,162 @@ +package com.garfiec.librechat.core.model.permissions + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UserRolePermissionsTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun hasAccessReturnsTrueWhenPermissionExplicitlyGranted() { + val role = UserRolePermissions( + name = "ADMIN", + permissions = mapOf("PROMPTS" to mapOf("USE" to true)), + ) + assertTrue(role.hasAccess(PermissionType.PROMPTS, Permission.USE)) + } + + @Test + fun hasAccessReturnsFalseWhenPermissionExplicitlyDenied() { + val role = UserRolePermissions( + name = "ADMIN", + permissions = mapOf("PROMPTS" to mapOf("USE" to false)), + ) + assertFalse(role.hasAccess(PermissionType.PROMPTS, Permission.USE)) + } + + @Test + fun hasAccessReturnsTrueWhenPermissionTypeMissing() { + val role = UserRolePermissions( + name = "ADMIN", + permissions = mapOf("PROMPTS" to mapOf("USE" to true)), + ) + // AGENTS type not in map → permissive default + assertTrue(role.hasAccess(PermissionType.AGENTS, Permission.USE)) + } + + @Test + fun hasAccessReturnsTrueWhenActionMissingWithinKnownType() { + val role = UserRolePermissions( + name = "ADMIN", + permissions = mapOf("PROMPTS" to mapOf("USE" to false)), + ) + // CREATE action absent under PROMPTS → permissive default + assertTrue(role.hasAccess(PermissionType.PROMPTS, Permission.CREATE)) + } + + @Test + fun hasAccessReturnsTrueForEveryPermissionWhenPermissionsMapEmpty() { + val role = UserRolePermissions(name = "ADMIN", permissions = emptyMap()) + assertTrue(role.hasAccess(PermissionType.PROMPTS, Permission.USE)) + assertTrue(role.hasAccess(PermissionType.AGENTS, Permission.CREATE)) + assertTrue(role.hasAccess(PermissionType.MCP_SERVERS, Permission.USE)) + } + + @Test + fun deserializesLiveRolesAdminResponseFromV085Rc1() { + // Captured 2026-04-24 from `GET /api/roles/ADMIN` on v0.8.5-rc1 test server + // with mongo roles seeded from an earlier USE=false configuration. + val decoded = json.decodeFromString(UserRolePermissions.serializer(), LIVE_ADMIN_ROLE_JSON) + + assertEquals("ADMIN", decoded.name) + assertEquals("", decoded.description) + + // Spot-check a mix of denied + granted permissions from the live response. + assertFalse(decoded.hasAccess(PermissionType.PROMPTS, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.PROMPTS, Permission.CREATE)) + assertFalse(decoded.hasAccess(PermissionType.AGENTS, Permission.USE)) + assertFalse(decoded.hasAccess(PermissionType.MEMORIES, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.MEMORIES, Permission.CREATE)) + assertFalse(decoded.hasAccess(PermissionType.MCP_SERVERS, Permission.USE)) + assertFalse(decoded.hasAccess(PermissionType.MARKETPLACE, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.BOOKMARKS, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.MULTI_CONVO, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.TEMPORARY_CHAT, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.RUN_CODE, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.WEB_SEARCH, Permission.USE)) + assertTrue(decoded.hasAccess(PermissionType.FILE_SEARCH, Permission.USE)) + assertFalse(decoded.hasAccess(PermissionType.REMOTE_AGENTS, Permission.USE)) + assertFalse(decoded.hasAccess(PermissionType.REMOTE_AGENTS, Permission.CREATE)) + } + + @Test + fun deserializesMinimalRolePayload() { + val minimal = """{"name":"USER","permissions":{}}""" + val decoded = json.decodeFromString(UserRolePermissions.serializer(), minimal) + assertEquals("USER", decoded.name) + assertTrue(decoded.permissions.isEmpty()) + assertTrue(decoded.hasAccess(PermissionType.PROMPTS, Permission.USE)) + } + + @Test + fun ignoresUnknownTopLevelFieldsFromServer() { + // Live payload includes `_id` and `__v` which aren't in our model; ensure it deserializes. + val json = Json { ignoreUnknownKeys = true } + val payload = """{"_id":"abc","name":"ADMIN","__v":0,"permissions":{"PROMPTS":{"USE":true}}}""" + val decoded = json.decodeFromString(UserRolePermissions.serializer(), payload) + assertEquals("ADMIN", decoded.name) + assertTrue(decoded.hasAccess(PermissionType.PROMPTS, Permission.USE)) + } + + private companion object { + // Verbatim response body from the running v0.8.5-rc1 test server, 2026-04-24. + private val LIVE_ADMIN_ROLE_JSON = """ + { + "_id": "69eab599e5c9757247ad5c21", + "name": "ADMIN", + "permissions": { + "BOOKMARKS": { "USE": true }, + "PROMPTS": { + "USE": false, + "CREATE": true, + "SHARE": false, + "SHARE_PUBLIC": false + }, + "MEMORIES": { + "USE": false, + "CREATE": true, + "UPDATE": true, + "READ": true, + "OPT_OUT": true + }, + "AGENTS": { + "USE": false, + "CREATE": true, + "SHARE": false, + "SHARE_PUBLIC": false + }, + "MULTI_CONVO": { "USE": true }, + "TEMPORARY_CHAT": { "USE": true }, + "RUN_CODE": { "USE": true }, + "WEB_SEARCH": { "USE": true }, + "PEOPLE_PICKER": { + "VIEW_USERS": true, + "VIEW_GROUPS": true, + "VIEW_ROLES": true + }, + "MARKETPLACE": { "USE": false }, + "FILE_SEARCH": { "USE": true }, + "FILE_CITATIONS": { "USE": true }, + "MCP_SERVERS": { + "USE": false, + "CREATE": true, + "SHARE": false, + "SHARE_PUBLIC": false + }, + "REMOTE_AGENTS": { + "USE": false, + "CREATE": false, + "SHARE": false, + "SHARE_PUBLIC": false + } + }, + "__v": 0, + "description": "" + } + """.trimIndent() + } +} diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/RolesApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/RolesApi.kt new file mode 100644 index 0000000..5ae0efe --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/RolesApi.kt @@ -0,0 +1,16 @@ +package com.garfiec.librechat.core.network.api + +import com.garfiec.librechat.core.model.permissions.UserRolePermissions +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.path + +class RolesApi( + private val client: HttpClient, +) { + suspend fun getRole(roleName: String): UserRolePermissions = + client.get { + url { path("api/roles/$roleName") } + }.body() +} diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt index 2f35f81..46ec102 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt @@ -17,6 +17,7 @@ import com.garfiec.librechat.core.network.api.MemoriesApi import com.garfiec.librechat.core.network.api.MessagesApi import com.garfiec.librechat.core.network.api.PresetsApi import com.garfiec.librechat.core.network.api.PromptsApi +import com.garfiec.librechat.core.network.api.RolesApi import com.garfiec.librechat.core.network.api.SearchApi import com.garfiec.librechat.core.network.api.ShareApi import com.garfiec.librechat.core.network.api.SpeechApi @@ -131,6 +132,7 @@ val networkModule = module { singleOf(::MessagesApi) singleOf(::PresetsApi) singleOf(::PromptsApi) + singleOf(::RolesApi) singleOf(::SearchApi) singleOf(::ShareApi) singleOf(::SpeechApi) diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ErrorBanner.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ErrorBanner.kt index 5449a5c..d756a95 100644 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ErrorBanner.kt +++ b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ErrorBanner.kt @@ -26,6 +26,10 @@ fun ErrorBanner( message: String, modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, + retryLabel: String = "Retry", + dismissLabel: String = "Dismiss", + errorContentDescription: String = "Error", ) { Card( modifier = modifier @@ -42,7 +46,7 @@ fun ErrorBanner( ) { Icon( imageVector = Icons.Default.Warning, - contentDescription = "Error", + contentDescription = errorContentDescription, tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.width(12.dp)) @@ -54,7 +58,12 @@ fun ErrorBanner( ) if (onRetry != null) { TextButton(onClick = onRetry) { - Text("Retry") + Text(retryLabel) + } + } + if (onDismiss != null) { + TextButton(onClick = onDismiss) { + Text(dismissLabel) } } } diff --git a/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModuleVerificationTest.kt b/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModuleVerificationTest.kt index a9766ec..a79da4e 100644 --- a/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModuleVerificationTest.kt +++ b/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModuleVerificationTest.kt @@ -6,6 +6,8 @@ import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.repository.AgentRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.McpRepository +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.data.util.PermissionGate import org.junit.Test import org.koin.test.verify.verify @@ -19,6 +21,8 @@ class AgentsModuleVerificationTest { AgentRepository::class, ConfigRepository::class, McpRepository::class, + RoleRepository::class, + PermissionGate::class, ServerDataStore::class, ), ) diff --git a/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt b/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt index 1e80391..e814bad 100644 --- a/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt +++ b/feature/agents/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt @@ -3,6 +3,8 @@ package com.garfiec.librechat.feature.agents.viewmodel import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.repository.AgentRepository +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.Agent import com.garfiec.librechat.core.model.AgentCategory import com.garfiec.librechat.core.model.PaginatedAgents @@ -13,6 +15,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -29,6 +32,8 @@ class AgentMarketplaceViewModelTest { private val agentRepository = mockk(relaxed = true) private val serverDataStore = mockk(relaxed = true) + private val roleRepository = mockk(relaxed = true) + private val permissionGate = mockk(relaxed = true) private lateinit var viewModel: AgentMarketplaceViewModel @@ -47,6 +52,12 @@ class AgentMarketplaceViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) + // Permissive-by-null StateFlow for role repository so the collector leaves + // flags at their default `true`. awaitRole() returns null (no timeout needed + // in tests — the suspend returns null immediately), so the gated loadAgents() + // path fires via `?: != false` permissive. + every { roleRepository.userPermissions } returns MutableStateFlow(null) + coEvery { permissionGate.awaitRole() } returns null every { serverDataStore.getBaseUrl() } returns "https://chat.example.com" coEvery { agentRepository.getAgentsPaginated( @@ -67,6 +78,8 @@ class AgentMarketplaceViewModelTest { private fun createViewModel() = AgentMarketplaceViewModel( agentRepository = agentRepository, serverDataStore = serverDataStore, + roleRepository = roleRepository, + permissionGate = permissionGate, ) @Test diff --git a/feature/agents/src/commonMain/composeResources/values/strings.xml b/feature/agents/src/commonMain/composeResources/values/strings.xml index e04065e..01d5b14 100644 --- a/feature/agents/src/commonMain/composeResources/values/strings.xml +++ b/feature/agents/src/commonMain/composeResources/values/strings.xml @@ -37,6 +37,8 @@ Search agents\u2026 No agents found No agents available + Agents not available + Your server administrator has disabled agents for your account. Try a different search term Check back later for new agents An unknown error occurred diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentMarketplaceScreen.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentMarketplaceScreen.kt index 8e9f9d9..32646f9 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentMarketplaceScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentMarketplaceScreen.kt @@ -97,13 +97,15 @@ fun AgentMarketplaceScreen( ) }, floatingActionButton = { - FloatingActionButton( - onClick = onCreateAgent, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(Res.string.cd_create_agent), - ) + if (uiState.agentsEnabled && uiState.agentsCreateEnabled) { + FloatingActionButton( + onClick = onCreateAgent, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.cd_create_agent), + ) + } } }, ) { innerPadding -> @@ -114,6 +116,17 @@ fun AgentMarketplaceScreen( .fillMaxSize() .padding(innerPadding), ) { + if (!uiState.agentsEnabled) { + // Role denies AGENTS.USE — render degraded empty state. Hide search, + // category chips, grid, and FAB. Matches the Settings MCP "not available" + // pattern for consistency across gated surfaces. + EmptyState( + title = stringResource(Res.string.agents_not_available_title), + description = stringResource(Res.string.agents_not_available_description), + icon = Icons.Default.SmartToy, + ) + return@PullToRefreshBox + } Column(modifier = Modifier.fillMaxSize()) { // Search bar OutlinedTextField( diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModel.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModel.kt index b5b134c..8be8d88 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentMarketplaceViewModel.kt @@ -6,7 +6,12 @@ import androidx.lifecycle.viewModelScope import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.repository.AgentRepository +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.Agent +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive import com.garfiec.librechat.feature.agents.AgentCardDisplayData import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -28,11 +33,17 @@ data class AgentMarketplaceUiState( val categories: List = emptyList(), val hasMore: Boolean = true, val currentPage: Int = 1, + // Role-permission gates — default permissive. + val agentsEnabled: Boolean = true, + val agentsCreateEnabled: Boolean = true, + val marketplaceEnabled: Boolean = true, ) class AgentMarketplaceViewModel( private val agentRepository: AgentRepository, private val serverDataStore: ServerDataStore, + private val roleRepository: RoleRepository, + private val permissionGate: PermissionGate, ) : ViewModel() { private val _uiState = MutableStateFlow(AgentMarketplaceUiState()) @@ -64,8 +75,32 @@ class AgentMarketplaceViewModel( } init { - loadAgents() + // Categories endpoint is ungated server-side — load unconditionally. loadCategories() + observePermissionFlags() + loadInitialAgents() + } + + /** Continuous collector mirroring the current role into UiState permission flags. */ + private fun observePermissionFlags() { + viewModelScope.launch { + roleRepository.userPermissions.collect { role -> + _uiState.value = _uiState.value.copy( + agentsEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.USE), + agentsCreateEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.CREATE), + marketplaceEnabled = role.hasAccessOrPermissive(PermissionType.MARKETPLACE, Permission.USE), + ) + } + } + } + + /** Fetches the first page of agents once the role confirms AGENTS.USE. Permissive on timeout. */ + private fun loadInitialAgents() { + viewModelScope.launch { + if (permissionGate.awaitRole()?.hasAccess(PermissionType.AGENTS, Permission.USE) != false) { + loadAgents() + } + } } fun loadAgents() { diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatInput.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatInput.kt index dba7834..0056e8f 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatInput.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatInput.kt @@ -85,6 +85,10 @@ fun ChatInput( onOpenModelSelector: () -> Unit = {}, selectedModelDisplay: String? = null, isCodeInterpreterAvailable: Boolean = true, + webSearchEnabled: Boolean = true, + runCodeEnabled: Boolean = true, + fileSearchEnabled: Boolean = true, + mcpServersEnabled: Boolean = true, ) { val cdOpenToolsMenu = stringResource(Res.string.cd_open_tools_menu) val cdPasteImage = stringResource(Res.string.cd_paste_image) @@ -396,6 +400,10 @@ fun ChatInput( selectedModelDisplay = selectedModelDisplay, onDismiss = { showToolsSheet = false }, isCodeInterpreterAvailable = isCodeInterpreterAvailable, + webSearchEnabled = webSearchEnabled, + runCodeEnabled = runCodeEnabled, + fileSearchEnabled = fileSearchEnabled, + mcpServersEnabled = mcpServersEnabled, ) } } diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt index e4d12b9..11123e1 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt @@ -32,6 +32,8 @@ actual val chatPlatformModule: Module = module { shareRepository = get(), mcpRepository = get(), userRepository = get(), + roleRepository = get(), + permissionGate = get(), connectivityObserver = get(), serverDataStore = get(), settingsDataStore = get(), diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt index 1cfff72..2ced5b8 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt @@ -95,6 +95,7 @@ import com.garfiec.librechat.feature.chat.resources.Res import com.garfiec.librechat.feature.chat.util.MessageNode import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel +import com.garfiec.librechat.feature.chat.viewmodel.asString import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -136,7 +137,6 @@ actual fun ChatScreen( val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager var showPresetPicker by remember { mutableStateOf(false) } var showSavePresetDialog by remember { mutableStateOf(false) } - var showModelSheet by remember { mutableStateOf(false) } var showSecondaryModelSheet by remember { mutableStateOf(false) } var activeComparisonTab by remember { mutableStateOf(0) } @@ -259,6 +259,8 @@ actual fun ChatScreen( } } + val sendBlockMessage = uiState.sendBlockReason?.asString() + // Stream resume on foreground LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { viewModel.onPause() } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onResume() } @@ -320,9 +322,11 @@ actual fun ChatScreen( onOpenDrawer = onOpenDrawer, onOpenSearch = viewModel::openSearch, onOpenPromptsLibrary = onNavigateToPromptsLibrary, + promptsEnabled = uiState.promptsEnabled, + multiConvoEnabled = uiState.multiConvoEnabled, isTemporaryChat = uiState.isTemporaryChat, onToggleTemporaryChat = viewModel::toggleTemporaryChat, - showTempChatToggle = uiState.conversationId == null, + showTempChatToggle = uiState.conversationId == null && uiState.temporaryChatEnabled, isComparisonEnabled = uiState.comparisonState.isEnabled, onToggleComparison = viewModel::toggleComparison, conversationId = uiState.conversationId, @@ -521,7 +525,7 @@ actual fun ChatScreen( primaryModelSelector = { ModelSelectorButton( modelName = displayModel, - onClick = { showModelSheet = true }, + onClick = viewModel::openModelSheet, ) }, secondaryModelSelector = { @@ -644,7 +648,7 @@ actual fun ChatScreen( if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) { showSecondaryModelSheet = true } else { - showModelSheet = true + viewModel.openModelSheet() } }, selectedModelDisplay = if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) { @@ -655,6 +659,10 @@ actual fun ChatScreen( displayModel }, isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable, + webSearchEnabled = uiState.webSearchEnabled, + runCodeEnabled = uiState.runCodeEnabled, + fileSearchEnabled = uiState.fileSearchEnabled, + mcpServersEnabled = uiState.mcpServersEnabled, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -728,7 +736,7 @@ actual fun ChatScreen( ) } - if (showModelSheet) { + if (uiState.showModelSheet) { ModelSelectorSheet( endpointConfigs = uiState.endpointConfigs, availableModels = uiState.availableModels, @@ -737,10 +745,26 @@ actual fun ChatScreen( selectedModel = uiState.selectedModel, onModelSelect = { endpoint, model -> viewModel.onModelSelected(endpoint, model) - showModelSheet = false + // Clear any pending scaffold-level snackbar for the same error so it + // doesn't flash behind the sheet's close animation. Harmless no-op when + // error is already null. + viewModel.dismissError() + viewModel.dismissSendBlockReason() + viewModel.dismissModelSheet() + }, + onDismiss = { + viewModel.dismissError() + viewModel.dismissSendBlockReason() + viewModel.dismissModelSheet() }, - onDismiss = { showModelSheet = false }, serverUrl = uiState.serverUrl, + // Send-block reasons take precedence: when set, the sheet was auto-opened + // to help the user resolve the block, so surface that context inline. + errorMessage = sendBlockMessage ?: uiState.error, + onErrorDismiss = { + viewModel.dismissSendBlockReason() + viewModel.dismissError() + }, ) } @@ -771,6 +795,8 @@ private fun ChatTopBar( modifier: Modifier = Modifier, onOpenSearch: () -> Unit = {}, onOpenPromptsLibrary: (() -> Unit)? = null, + promptsEnabled: Boolean = true, + multiConvoEnabled: Boolean = true, isTemporaryChat: Boolean = false, onToggleTemporaryChat: () -> Unit = {}, showTempChatToggle: Boolean = false, @@ -870,7 +896,7 @@ private fun ChatTopBar( Icon(Icons.Outlined.SaveAs, contentDescription = null) }, ) - if (onOpenPromptsLibrary != null) { + if (onOpenPromptsLibrary != null && promptsEnabled) { DropdownMenuItem( text = { Text(stringResource(Res.string.prompts_library)) }, onClick = { @@ -882,32 +908,34 @@ private fun ChatTopBar( }, ) } - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(Res.string.compare_models), - modifier = Modifier.weight(1f), - ) - if (isComparisonEnabled) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(Res.string.cd_comparison_enabled), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), + if (multiConvoEnabled) { + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.compare_models), + modifier = Modifier.weight(1f), ) + if (isComparisonEnabled) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(Res.string.cd_comparison_enabled), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } } - } - }, - onClick = { - showOverflowMenu = false - onToggleComparison() - }, - leadingIcon = { - Icon(Icons.Outlined.Compare, contentDescription = null) - }, - ) + }, + onClick = { + showOverflowMenu = false + onToggleComparison() + }, + leadingIcon = { + Icon(Icons.Outlined.Compare, contentDescription = null) + }, + ) + } if (conversationId != null) { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text( diff --git a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt index 9bb5d54..3c2f610 100644 --- a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt +++ b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt @@ -15,9 +15,11 @@ import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MessageRepository import com.garfiec.librechat.core.data.repository.PresetRepository import com.garfiec.librechat.core.data.repository.PromptRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.SpeechRepository import com.garfiec.librechat.core.data.repository.UserRepository +import com.garfiec.librechat.core.data.util.PermissionGate import org.junit.Test import org.koin.test.verify.verify @@ -37,6 +39,8 @@ class ChatModuleVerificationTest { FileRepository::class, PresetRepository::class, PromptRepository::class, + RoleRepository::class, + PermissionGate::class, ShareRepository::class, SpeechRepository::class, McpRepository::class, diff --git a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiStateSendReadyTest.kt b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiStateSendReadyTest.kt new file mode 100644 index 0000000..d9bad35 --- /dev/null +++ b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiStateSendReadyTest.kt @@ -0,0 +1,95 @@ +package com.garfiec.librechat.feature.chat.viewmodel + +import com.garfiec.librechat.core.common.EndpointConstants +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Tests for [ChatUiState.isSendReady], the pre-send gate used by [ChatViewModel]'s + * `runWhenSendReady` helper to avoid the cold-start race where endpoint/config hasn't + * arrived yet and firing startChat would produce a mislabeled 403. + * + * Pure-function tests — no ViewModel instantiation, no mockk overhead. + */ +class ChatUiStateSendReadyTest { + + private val anthropic = "anthropic" + private val haiku = "claude-haiku-4-5-20251001" + + @Test + fun `not ready when availableModels is empty`() { + val state = ChatUiState( + selectedEndpoint = anthropic, + selectedModel = haiku, + agentsEnabled = true, + availableModels = emptyMap(), + ) + assertThat(state.isSendReady).isFalse() + } + + @Test + fun `ready when non-agents endpoint selected and models available`() { + val state = ChatUiState( + selectedEndpoint = anthropic, + selectedModel = haiku, + agentsEnabled = true, + availableModels = mapOf(anthropic to listOf(haiku)), + ) + assertThat(state.isSendReady).isTrue() + } + + @Test + fun `not ready when agents endpoint selected but AGENTS USE denied`() { + // This is the reporter's scenario: cold start with agents as default + // selection, but role has loaded and denied AGENTS.USE. A send right now + // would hit /api/agents/chat/agents and 403. + val state = ChatUiState( + selectedEndpoint = EndpointConstants.AGENTS, + agentsEnabled = false, + availableModels = mapOf(anthropic to listOf(haiku)), + ) + assertThat(state.isSendReady).isFalse() + } + + @Test + fun `ready when agents endpoint selected and AGENTS USE granted`() { + val state = ChatUiState( + selectedEndpoint = EndpointConstants.AGENTS, + agentsEnabled = true, + availableModels = mapOf( + EndpointConstants.AGENTS to listOf("someAgentId"), + anthropic to listOf(haiku), + ), + ) + assertThat(state.isSendReady).isTrue() + } + + @Test + fun `not ready when only agents endpoint is offered by server but AGENTS USE denied`() { + // Edge: server exposes only the agents endpoint in availableModels, role denies. + // No viable send path — stays not-ready. runWhenSendReady will time out and toast. + val state = ChatUiState( + selectedEndpoint = EndpointConstants.AGENTS, + agentsEnabled = false, + availableModels = mapOf(EndpointConstants.AGENTS to listOf("someAgentId")), + ) + assertThat(state.isSendReady).isFalse() + } + + @Test + fun `ready when non-agents endpoint selected and model list for that endpoint is empty`() { + // isSendReady only cares that availableModels is non-empty and the selected + // endpoint isn't a known-denied one. An empty model list on the current endpoint + // is the user's concern (pick a model), not the race guard's. + val state = ChatUiState( + selectedEndpoint = anthropic, + selectedModel = null, + agentsEnabled = true, + availableModels = mapOf( + EndpointConstants.AGENTS to listOf("someAgentId"), + anthropic to emptyList(), + ), + ) + assertThat(state.isSendReady).isTrue() + } +} diff --git a/feature/chat/src/commonMain/composeResources/values/strings.xml b/feature/chat/src/commonMain/composeResources/values/strings.xml index aa11f23..03fcde2 100644 --- a/feature/chat/src/commonMain/composeResources/values/strings.xml +++ b/feature/chat/src/commonMain/composeResources/values/strings.xml @@ -327,4 +327,12 @@ /%1$s + + + Select an agent before sending your message. + Select a model before sending your message. + Agents aren\'t available on this server. Please pick a different model. + The selected agent isn\'t available on this server. Please pick a different agent. + The selected model isn\'t available on this server. Please pick a different model. + Couldn\'t load the selected model yet. Please try again or pick a different model. diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatToolsBottomSheet.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatToolsBottomSheet.kt index 988d238..1dcbf39 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatToolsBottomSheet.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ChatToolsBottomSheet.kt @@ -75,6 +75,10 @@ fun ChatToolsBottomSheet( onDismiss: () -> Unit, modifier: Modifier = Modifier, isCodeInterpreterAvailable: Boolean = true, + webSearchEnabled: Boolean = true, + runCodeEnabled: Boolean = true, + fileSearchEnabled: Boolean = true, + mcpServersEnabled: Boolean = true, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showMcpServers by remember { mutableStateOf(false) } @@ -204,15 +208,17 @@ fun ChatToolsBottomSheet( Spacer(modifier = Modifier.height(8.dp)) // Tool toggle items - ToolToggleRow( - icon = Icons.Default.Search, - title = stringResource(Res.string.tool_web_search), - subtitle = stringResource(Res.string.tool_web_search_desc), - isEnabled = ToolConstants.WEB_SEARCH in enabledTools, - onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) }, - ) + if (webSearchEnabled) { + ToolToggleRow( + icon = Icons.Default.Search, + title = stringResource(Res.string.tool_web_search), + subtitle = stringResource(Res.string.tool_web_search_desc), + isEnabled = ToolConstants.WEB_SEARCH in enabledTools, + onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) }, + ) + } - if (isCodeInterpreterAvailable) { + if (isCodeInterpreterAvailable && runCodeEnabled) { ToolToggleRow( icon = Icons.Default.Code, title = stringResource(Res.string.tool_code), @@ -222,16 +228,18 @@ fun ChatToolsBottomSheet( ) } - ToolToggleRow( - icon = Icons.Default.FindInPage, - title = stringResource(Res.string.tool_file_search), - subtitle = stringResource(Res.string.tool_file_search_desc), - isEnabled = ToolConstants.FILE_SEARCH in enabledTools, - onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) }, - ) + if (fileSearchEnabled) { + ToolToggleRow( + icon = Icons.Default.FindInPage, + title = stringResource(Res.string.tool_file_search), + subtitle = stringResource(Res.string.tool_file_search_desc), + isEnabled = ToolConstants.FILE_SEARCH in enabledTools, + onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) }, + ) + } - // MCP section - if (mcpServers.isNotEmpty()) { + // MCP section — hidden entirely when role denies MCP_SERVERS.USE. + if (mcpServersEnabled && mcpServers.isNotEmpty()) { val anyMcpSelected = selectedMcpServerNames.isNotEmpty() Row( modifier = Modifier diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt index fa3c96e..3958a7a 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt @@ -48,6 +48,7 @@ import coil3.compose.AsyncImage import com.garfiec.librechat.core.common.EndpointConstants import com.garfiec.librechat.core.model.Agent import com.garfiec.librechat.core.model.EndpointConfig +import com.garfiec.librechat.core.ui.components.ErrorBanner import com.garfiec.librechat.core.ui.components.endpointIconPainter import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon import com.garfiec.librechat.feature.chat.resources.* @@ -96,6 +97,8 @@ fun ModelSelectorSheet( onDismiss: () -> Unit, modifier: Modifier = Modifier, serverUrl: String = "", + errorMessage: String? = null, + onErrorDismiss: () -> Unit = {}, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var searchQuery by remember { mutableStateOf("") } @@ -144,20 +147,28 @@ fun ModelSelectorSheet( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp) .padding(bottom = 32.dp), ) { + // Banner applies its own 16dp inset, so it sits outside the Column's + // horizontal padding to line up with the other elements. + if (errorMessage != null) { + ErrorBanner(message = errorMessage, onDismiss = onErrorDismiss) + } Text( text = stringResource(Res.string.select_a_model), style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 16.dp), + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), ) OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, placeholder = { Text("Search models...") }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), singleLine = true, trailingIcon = { if (searchQuery.isNotEmpty()) { @@ -173,7 +184,11 @@ fun ModelSelectorSheet( Spacer(modifier = Modifier.height(12.dp)) - LazyColumn(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { // "My Agents" group (shown first, like the web frontend) if (filteredAgents.isNotEmpty()) { val agentsExpanded = expandedGroups[EndpointConstants.AGENTS] != false diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsLibraryScreen.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsLibraryScreen.kt index 13f9798..43b7922 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsLibraryScreen.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsLibraryScreen.kt @@ -155,8 +155,10 @@ fun PromptsLibraryScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) }, floatingActionButton = { - FloatingActionButton(onClick = { onNavigateToEditor(null) }) { - Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.cd_create_prompt)) + if (uiState.promptsCreateEnabled) { + FloatingActionButton(onClick = { onNavigateToEditor(null) }) { + Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.cd_create_prompt)) + } } }, ) { innerPadding -> @@ -255,6 +257,7 @@ fun PromptsLibraryScreen( group = group, onClick = { viewModel.selectGroup(group.id) }, onShare = { viewModel.showShareDialog(group.id) }, + showShareButton = uiState.promptsShareEnabled, ) } } @@ -281,6 +284,7 @@ private fun PromptGroupItem( onClick: () -> Unit, onShare: () -> Unit, modifier: Modifier = Modifier, + showShareButton: Boolean = true, ) { Card( modifier = modifier @@ -321,12 +325,14 @@ private fun PromptGroupItem( ) } } - IconButton(onClick = onShare) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(Res.string.cd_share), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (showShareButton) { + IconButton(onClick = onShare) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(Res.string.cd_share), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } val promptText = group.promptText diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt index 76ef068..8bc5892 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt @@ -6,7 +6,12 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.repository.PromptRepository +import com.garfiec.librechat.core.data.repository.RoleRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.PromptGroup +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptData import com.garfiec.librechat.core.model.request.CreatePromptGroupData @@ -42,10 +47,15 @@ data class PromptsUiState( val showVariableDialog: Boolean = false, val variablePromptTemplate: String = "", val variableNames: List = emptyList(), + // Role-permission gates — default permissive. + val promptsCreateEnabled: Boolean = true, + val promptsShareEnabled: Boolean = true, ) class PromptsViewModel( private val promptRepository: PromptRepository, + private val roleRepository: RoleRepository, + private val permissionGate: PermissionGate, ) : ViewModel() { private val _uiState = MutableStateFlow(PromptsUiState()) @@ -55,7 +65,29 @@ class PromptsViewModel( private var rawGroups: List = emptyList() init { - loadGroups() + observePermissionFlags() + loadInitialGroups() + } + + /** Continuous collector mirroring CREATE/SHARE sub-action flags into UiState. */ + private fun observePermissionFlags() { + viewModelScope.launch { + roleRepository.userPermissions.collect { role -> + _uiState.value = _uiState.value.copy( + promptsCreateEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.CREATE), + promptsShareEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.SHARE), + ) + } + } + } + + /** Fetches the first page of prompt groups once the role confirms PROMPTS.USE. Permissive on timeout. */ + private fun loadInitialGroups() { + viewModelScope.launch { + if (permissionGate.awaitRole()?.hasAccess(PermissionType.PROMPTS, Permission.USE) != false) { + loadGroups() + } + } } fun loadGroups() { diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt index 82dbd41..c6bca00 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt @@ -18,6 +18,19 @@ import com.garfiec.librechat.feature.chat.util.MessageNode enum class ChatScreenState { LANDING, LOADING, ACTIVE } +/** + * Reasons why a send attempt was blocked. Resolved to a user-facing string in the Compose + * layer via `stringResource`, so the ViewModel stays free of hard-coded English UI copy. + */ +sealed interface SendBlockReason { + data object SelectAgent : SendBlockReason + data object SelectModel : SendBlockReason + data object AgentsUnavailable : SendBlockReason + data object AgentNotAvailable : SendBlockReason + data object ModelNotAvailable : SendBlockReason + data object ModelLoadFailed : SendBlockReason +} + /** * Consolidated chat-related user preferences from [SettingsDataStore]. * Exposed as a single [StateFlow] to reduce the number of individual subscriptions @@ -82,6 +95,9 @@ data class ChatUiState( val agents: List = emptyList(), val conversationId: String? = null, val error: String? = null, + /** Set when a send was blocked for a selection/readiness reason. Resolved to a + * user-facing string in the Compose layer. Null means no send-block to show. */ + val sendBlockReason: SendBlockReason? = null, val presets: List = emptyList(), val availablePrompts: List = emptyList(), val editingMessageId: String? = null, @@ -131,8 +147,25 @@ data class ChatUiState( * The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled], * which also resets this ViewModel to a clean landing state. */ val pendingNavigationConversationId: String? = null, + /** Single source of truth for whether the model-selector sheet is open. Preflight + * failures and readiness timeouts flip this to true so the user sees the sheet with + * a send-block banner. Manual taps on the model chip also set it true via + * [ChatViewModel.openModelSheet]. UI dismissal routes through [ChatViewModel.dismissModelSheet]. */ + val showModelSheet: Boolean = false, // Model comparison state val comparisonState: ComparisonState = ComparisonState(), + // Role-permission gates — default permissive; narrowed once RoleRepository emits. + val promptsEnabled: Boolean = true, + val promptsCreateEnabled: Boolean = true, + val agentsEnabled: Boolean = true, + val agentsCreateEnabled: Boolean = true, + val mcpServersEnabled: Boolean = true, + val multiConvoEnabled: Boolean = true, + val temporaryChatEnabled: Boolean = true, + val webSearchEnabled: Boolean = true, + val runCodeEnabled: Boolean = true, + val fileSearchEnabled: Boolean = true, + val bookmarksEnabled: Boolean = true, ) { /** * Effective tool set that merges [enabledTools] with the web search state from @@ -159,4 +192,22 @@ data class ChatUiState( // the actual capabilities list is authoritative. return agentsConfig?.capabilities?.contains(ToolConstants.EXECUTE_CODE) ?: true } + + /** + * True when firing `chatRepository.startChat(...)` is unlikely to race against + * cold-start initialization. + * + * Conditions: + * - [availableModels] is non-empty (server's endpoint/model list has arrived). + * - The currently-selected endpoint isn't a known-denied one. Specifically: if + * [selectedEndpoint] == "agents", [agentsEnabled] must be true. + * + * Not included: [selectedModel] != null. The agents endpoint auto-selects a + * default agent when none is set (see ModelSelectionDelegate.loadAgents) and + * the non-agents flows always set model+endpoint together, so a null here is + * orthogonal to the cold-start race we're guarding against. + */ + val isSendReady: Boolean + get() = availableModels.isNotEmpty() && + (selectedEndpoint != EndpointConstants.AGENTS || agentsEnabled) } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt index a6eb534..33eeb8a 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt @@ -20,13 +20,18 @@ import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MessageRepository import com.garfiec.librechat.core.data.repository.PresetRepository import com.garfiec.librechat.core.data.repository.PromptRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.UserRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.Attachment import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.Message import com.garfiec.librechat.core.model.Preset import com.garfiec.librechat.core.model.StreamEvent +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive import com.garfiec.librechat.core.model.request.EphemeralAgent import com.garfiec.librechat.core.ui.components.ModelParameters import com.garfiec.librechat.feature.chat.components.AttachedFile @@ -48,16 +53,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.Clock import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +@Suppress("TooManyFunctions") class ChatViewModel( initialConversationId: String? = null, private val agentRepository: AgentRepository, @@ -71,6 +79,8 @@ class ChatViewModel( shareRepository: ShareRepository, mcpRepository: McpRepository, private val userRepository: UserRepository, + private val roleRepository: RoleRepository, + private val permissionGate: PermissionGate, private val connectivityObserver: ConnectivityObserver, serverDataStore: ServerDataStore, private val settingsDataStore: SettingsDataStore, @@ -93,7 +103,7 @@ class ChatViewModel( ConversationActionsDelegate(stateHandle, conversationRepository, shareRepository) private val presetPromptDelegate = PresetPromptDelegate(stateHandle, presetRepository, promptRepository) private val modelDelegate = - ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore) + ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore, permissionGate) // --- Delegate-owned flows exposed to the UI --- val attachedFiles: StateFlow> get() = fileDelegate.attachedFiles @@ -153,6 +163,11 @@ class ChatViewModel( companion object { /** Minimum interval between streaming UI state updates to avoid recomposition spam. */ private const val STREAMING_UI_UPDATE_INTERVAL_MS = 50L + + /** Timeout for the pre-send "is the endpoint/config ready" await. Snappier than the + * 5 s role-load timeout because this only needs one of role OR availableModels to + * satisfy the check. */ + private const val SEND_READY_TIMEOUT_MS = 3_000L } /** True when the current stream is from an edit, regenerate, or continue operation. */ @@ -272,12 +287,26 @@ class ChatViewModel( } presetPromptDelegate.loadPresets() - presetPromptDelegate.loadAvailablePrompts() - modelDelegate.loadMcpServers() loadUserProfile() - modelDelegate.loadAgents() - loadSharedLinksEnabled() + loadFlags() voiceDelegate.loadSpeechConfig() + + // Gated loads share a single 5-second role-await budget so offline/timeout + // launches don't serialize into N×5s. `role?.hasAccess(...) != false` + // preserves permissive default: null role (timeout/never-loaded) → true, + // missing type/action → true, explicit false → false. + viewModelScope.launch { + val role = permissionGate.awaitRole() + if (role?.hasAccess(PermissionType.PROMPTS, Permission.USE) != false) { + presetPromptDelegate.loadAvailablePrompts() + } + if (role?.hasAccess(PermissionType.MCP_SERVERS, Permission.USE) != false) { + modelDelegate.loadMcpServers() + } + if (role?.hasAccess(PermissionType.AGENTS, Permission.USE) != false) { + modelDelegate.loadAgents() + } + } } // ── Core chat flow ────────────────────────────────────────────── @@ -426,12 +455,12 @@ class ChatViewModel( if (fileDelegate.hasPendingUploads()) { Logger.d { "sendMessage: waiting for pending upload(s) to complete" } fileDelegate.pendingUploadSendJob = viewModelScope.launch { - fileDelegate.waitForUploadsAndSend(text) { doSendMessage(it) } + fileDelegate.waitForUploadsAndSend(text) { runWhenSendReady { doSendMessage(it) } } } return } - doSendMessage(text) + runWhenSendReady { doSendMessage(text) } } /** @@ -637,10 +666,12 @@ class ChatViewModel( val originalMessage = _uiState.value.messages.find { it.messageId == messageId } ?: return - if (originalMessage.isCreatedByUser) { - editUserMessage(originalMessage, newText) - } else { - editAiMessage(originalMessage, newText) + runWhenSendReady { + if (originalMessage.isCreatedByUser) { + editUserMessage(originalMessage, newText) + } else { + editAiMessage(originalMessage, newText) + } } } @@ -717,6 +748,10 @@ class ChatViewModel( it.messageId == aiMessage.parentMessageId } ?: return + runWhenSendReady { regenerateMessageNow(parentUserMessage) } + } + + private fun regenerateMessageNow(parentUserMessage: Message) { isEditOrRegenerate = true prepareForStreaming() @@ -1114,6 +1149,10 @@ class ChatViewModel( it.messageId == lastAiMessage.message.parentMessageId } ?: return + runWhenSendReady { continueGenerationNow(lastAiMessage.message, parentUserMessage) } + } + + private fun continueGenerationNow(lastAiMessage: Message, parentUserMessage: Message) { isEditOrRegenerate = true prepareForStreaming() @@ -1132,7 +1171,7 @@ class ChatViewModel( parentMessageId = parentUserMessage.parentMessageId, agentId = if (isAgentContinue) _uiState.value.selectedModel else null, overrideParentMessageId = parentUserMessage.messageId, - responseMessageId = lastAiMessage.message.messageId, + responseMessageId = lastAiMessage.messageId, isEdited = true, isRegenerate = true, isContinued = true, @@ -1363,14 +1402,133 @@ class ChatViewModel( } } - private fun loadSharedLinksEnabled() { + private fun loadFlags() { + // startupConfig-driven flags (UI-only toggles, not permission gates). viewModelScope.launch { configRepository.startupConfig.collect { config -> - _uiState.value = _uiState.value.copy( - sharedLinksEnabled = config?.sharedLinksEnabled ?: false, - ) + _uiState.update { + it.copy(sharedLinksEnabled = config?.sharedLinksEnabled ?: false) + } } } + // Role-permission-driven flags. Permissive default: null role (not loaded) → true; + // missing type/action in the map → true (see UserRolePermissions.hasAccess). + viewModelScope.launch { + roleRepository.userPermissions.collect { role -> + _uiState.update { + it.copy( + promptsEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.USE), + promptsCreateEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.CREATE), + agentsEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.USE), + agentsCreateEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.CREATE), + mcpServersEnabled = role.hasAccessOrPermissive(PermissionType.MCP_SERVERS, Permission.USE), + multiConvoEnabled = role.hasAccessOrPermissive(PermissionType.MULTI_CONVO, Permission.USE), + temporaryChatEnabled = role.hasAccessOrPermissive(PermissionType.TEMPORARY_CHAT, Permission.USE), + webSearchEnabled = role.hasAccessOrPermissive(PermissionType.WEB_SEARCH, Permission.USE), + runCodeEnabled = role.hasAccessOrPermissive(PermissionType.RUN_CODE, Permission.USE), + fileSearchEnabled = role.hasAccessOrPermissive(PermissionType.FILE_SEARCH, Permission.USE), + bookmarksEnabled = role.hasAccessOrPermissive(PermissionType.BOOKMARKS, Permission.USE), + ) + } + } + } + } + + /** + * Suspends until [ChatUiState.isSendReady] becomes true, up to [timeoutMs]. Returns + * true if the state became ready; false on timeout. Used as a pre-flight guard on all + * send variants to avoid the cold-start race where endpoint/config hasn't arrived yet + * and firing `chatRepository.startChat(...)` would produce a mislabeled 403. + * + * 3 s chosen to be snappier than the role-load timeout (5 s) since this only needs + * one of the async inits (role OR availableModels) to complete enough to satisfy + * `isSendReady` — usually both have landed by the time a human can tap send. + */ + private suspend fun awaitSendReady(timeoutMs: Long = SEND_READY_TIMEOUT_MS): Boolean { + if (_uiState.value.isSendReady) return true + return withTimeoutOrNull(timeoutMs) { + _uiState.map { it.isSendReady }.distinctUntilChanged().first { it } + } != null + } + + /** + * Guard for each of the four send variants (send / edit / regenerate / continue). + * Runs a synchronous pre-flight that fails fast on user-input errors (e.g., no model + * selected, agents denied with role already loaded) so the user isn't made to wait + * for the readiness timeout just to be told something they could have acted on + * immediately. Otherwise, awaits readiness up to 3 s and falls back to a + * selection-aware availability message if the wait times out. + */ + private fun runWhenSendReady(action: () -> Unit) { + val current = _uiState.value + preflightSendBlockReason(current)?.let { reason -> + _uiState.update { it.copy(sendBlockReason = reason, showModelSheet = true) } + return + } + if (current.isSendReady) { + action() + return + } + viewModelScope.launch { + if (awaitSendReady()) { + action() + } else { + _uiState.update { + it.copy( + sendBlockReason = sendReadinessTimeoutReason(it), + showModelSheet = true, + ) + } + } + } + } + + /** + * Synchronous pre-flight. Returns a typed reason when sending is guaranteed + * to fail regardless of outstanding async inits; null when we still need to wait + * for the readiness signal. This keeps "no model selected" and "agents denied" + * instantaneous instead of waiting out the readiness timeout. + */ + private fun preflightSendBlockReason(state: ChatUiState): SendBlockReason? { + if (state.selectedModel == null) { + return if (state.selectedEndpoint == EndpointConstants.AGENTS) { + SendBlockReason.SelectAgent + } else { + SendBlockReason.SelectModel + } + } + if (state.selectedEndpoint == EndpointConstants.AGENTS && !state.agentsEnabled) { + return SendBlockReason.AgentsUnavailable + } + return null + } + + /** + * Fallback for when readiness didn't resolve within the timeout. At this point the + * async model list is most likely in its final shape, so we can confidently flag + * stale selections that aren't in the available models. + */ + private fun sendReadinessTimeoutReason(state: ChatUiState): SendBlockReason { + if (state.selectedEndpoint == EndpointConstants.AGENTS) { + return SendBlockReason.AgentNotAvailable + } + val modelsForEndpoint = state.availableModels[state.selectedEndpoint].orEmpty() + val selectedModel = state.selectedModel + return if (selectedModel != null && selectedModel !in modelsForEndpoint) { + SendBlockReason.ModelNotAvailable + } else { + SendBlockReason.ModelLoadFailed + } + } + + /** Opens the model-selector sheet. Called when the user taps the model chip. */ + fun openModelSheet() { + _uiState.update { it.copy(showModelSheet = true) } + } + + /** Dismisses the model-selector sheet. Called on sheet dismiss and model selection. */ + fun dismissModelSheet() { + _uiState.update { it.copy(showModelSheet = false) } } private fun loadUserProfile() { @@ -1395,6 +1553,10 @@ class ChatViewModel( _uiState.value = _uiState.value.copy(error = null) } + fun dismissSendBlockReason() { + _uiState.value = _uiState.value.copy(sendBlockReason = null) + } + override fun onCleared() { super.onCleared() voiceDelegate.release() diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/SendBlockReasonStrings.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/SendBlockReasonStrings.kt new file mode 100644 index 0000000..3c61e66 --- /dev/null +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/SendBlockReasonStrings.kt @@ -0,0 +1,21 @@ +package com.garfiec.librechat.feature.chat.viewmodel + +import androidx.compose.runtime.Composable +import com.garfiec.librechat.feature.chat.resources.Res +import com.garfiec.librechat.feature.chat.resources.send_block_agent_not_available +import com.garfiec.librechat.feature.chat.resources.send_block_agents_unavailable +import com.garfiec.librechat.feature.chat.resources.send_block_model_load_failed +import com.garfiec.librechat.feature.chat.resources.send_block_model_not_available +import com.garfiec.librechat.feature.chat.resources.send_block_select_agent +import com.garfiec.librechat.feature.chat.resources.send_block_select_model +import org.jetbrains.compose.resources.stringResource + +@Composable +fun SendBlockReason.asString(): String = when (this) { + SendBlockReason.SelectAgent -> stringResource(Res.string.send_block_select_agent) + SendBlockReason.SelectModel -> stringResource(Res.string.send_block_select_model) + SendBlockReason.AgentsUnavailable -> stringResource(Res.string.send_block_agents_unavailable) + SendBlockReason.AgentNotAvailable -> stringResource(Res.string.send_block_agent_not_available) + SendBlockReason.ModelNotAvailable -> stringResource(Res.string.send_block_model_not_available) + SendBlockReason.ModelLoadFailed -> stringResource(Res.string.send_block_model_load_failed) +} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt index f660b3e..be1666f 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt @@ -8,8 +8,11 @@ import com.garfiec.librechat.core.data.datastore.SettingsDataStore import com.garfiec.librechat.core.data.repository.AgentRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.McpRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.mcp.McpServer +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType import com.garfiec.librechat.core.model.request.AddedConversation import com.garfiec.librechat.core.ui.components.ModelParameters import com.garfiec.librechat.feature.chat.model.McpServerDisplayData @@ -24,6 +27,7 @@ class ModelSelectionDelegate( private val agentRepository: AgentRepository, private val mcpRepository: McpRepository, private val settingsDataStore: SettingsDataStore, + private val permissionGate: PermissionGate, ) { // --- Model Comparison --- @@ -250,6 +254,12 @@ class ModelSelectionDelegate( fun loadAgents() { stateHandle.scope.launch { + // Skip the fetch entirely when the role denies AGENTS.USE; otherwise + // the server would return 403 and we'd have to decide whether it's a + // genuine 403 (rate limit, tenancy) vs. permission denial. + if (permissionGate.awaitRole()?.hasAccess(PermissionType.AGENTS, Permission.USE) == false) { + return@launch + } when (val result = agentRepository.getAgents()) { is Result.Success -> { stateHandle.update { copy(agents = result.data) } diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/IosChatInput.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/IosChatInput.kt index 5c518cb..b608f0d 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/IosChatInput.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/IosChatInput.kt @@ -60,6 +60,10 @@ fun IosChatInput( onAttachFiles: () -> Unit = {}, onTakePhoto: () -> Unit = {}, onPickPhotos: () -> Unit = {}, + webSearchEnabled: Boolean = true, + runCodeEnabled: Boolean = true, + fileSearchEnabled: Boolean = true, + mcpServersEnabled: Boolean = true, ) { val snackbarHostState = remember { SnackbarHostState() } var showToolsSheet by remember { mutableStateOf(false) } @@ -208,6 +212,10 @@ fun IosChatInput( selectedModelDisplay = selectedModelDisplay, onDismiss = { showToolsSheet = false }, isCodeInterpreterAvailable = isCodeInterpreterAvailable, + webSearchEnabled = webSearchEnabled, + runCodeEnabled = runCodeEnabled, + fileSearchEnabled = fileSearchEnabled, + mcpServersEnabled = mcpServersEnabled, ) } } diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt index 477784d..db0e367 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt @@ -30,6 +30,8 @@ actual val chatPlatformModule: Module = module { shareRepository = get(), mcpRepository = get(), userRepository = get(), + roleRepository = get(), + permissionGate = get(), connectivityObserver = get(), serverDataStore = get(), settingsDataStore = get(), diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt index 98222da..475f0c4 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt @@ -79,6 +79,7 @@ import com.garfiec.librechat.feature.chat.util.openPhotoPicker import com.garfiec.librechat.feature.chat.util.readClipboardImage import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel +import com.garfiec.librechat.feature.chat.viewmodel.asString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -134,7 +135,6 @@ actual fun ChatScreen( val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() var showOptionsMenu by remember { mutableStateOf(false) } - var showModelSheet by remember { mutableStateOf(false) } var showPresetPicker by remember { mutableStateOf(false) } var showSavePresetDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } @@ -157,6 +157,8 @@ actual fun ChatScreen( } } + val sendBlockMessage = uiState.sendBlockReason?.asString() + Scaffold( modifier = modifier.imePadding(), snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -176,7 +178,7 @@ actual fun ChatScreen( // Model selector only in active chat (not landing page) ModelSelectorButton( modelName = displayModel, - onClick = { showModelSheet = true }, + onClick = viewModel::openModelSheet, ) } }, @@ -191,8 +193,8 @@ actual fun ChatScreen( } }, actions = { - // Temp chat toggle — only on landing page - if (isLandingPage) { + // Temp chat toggle — only on landing page, gated on TEMPORARY_CHAT.USE + if (isLandingPage && uiState.temporaryChatEnabled) { TempChatToggle( isTemporary = uiState.isTemporaryChat, onToggle = viewModel::toggleTemporaryChat, @@ -245,8 +247,8 @@ actual fun ChatScreen( Icon(Icons.Outlined.SaveAs, contentDescription = null) }, ) - // Prompts Library - if (onNavigateToPromptsLibrary != null) { + // Prompts Library — gated on PROMPTS.USE + if (onNavigateToPromptsLibrary != null && uiState.promptsEnabled) { DropdownMenuItem( text = { Text(stringResource(Res.string.prompts_library)) }, onClick = { @@ -258,33 +260,35 @@ actual fun ChatScreen( }, ) } - // Compare Models - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(Res.string.compare_models), - modifier = Modifier.weight(1f), - ) - if (uiState.comparisonState.isEnabled) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(Res.string.cd_comparison_enabled), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), + // Compare Models — gated on MULTI_CONVO.USE + if (uiState.multiConvoEnabled) { + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.compare_models), + modifier = Modifier.weight(1f), ) + if (uiState.comparisonState.isEnabled) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(Res.string.cd_comparison_enabled), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } } - } - }, - onClick = { - showOptionsMenu = false - viewModel.toggleComparison() - }, - leadingIcon = { - Icon(Icons.Outlined.Compare, contentDescription = null) - }, - ) + }, + onClick = { + showOptionsMenu = false + viewModel.toggleComparison() + }, + leadingIcon = { + Icon(Icons.Outlined.Compare, contentDescription = null) + }, + ) + } // Conversation-specific actions if (uiState.conversationId != null) { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) @@ -481,7 +485,7 @@ actual fun ChatScreen( onStartRecording = viewModel::startRecording, onStopRecording = viewModel::stopRecording, onOpenModelParameters = viewModel::showModelParameters, - onOpenModelSelector = { showModelSheet = true }, + onOpenModelSelector = viewModel::openModelSheet, selectedModelDisplay = displayModel, isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable, attachedFiles = attachedFiles, @@ -516,13 +520,17 @@ actual fun ChatScreen( } } }, + webSearchEnabled = uiState.webSearchEnabled, + runCodeEnabled = uiState.runCodeEnabled, + fileSearchEnabled = uiState.fileSearchEnabled, + mcpServersEnabled = uiState.mcpServersEnabled, modifier = Modifier.align(Alignment.BottomCenter), ) } } // Model selector bottom sheet - if (showModelSheet) { + if (uiState.showModelSheet) { ModelSelectorSheet( endpointConfigs = uiState.endpointConfigs, availableModels = uiState.availableModels, @@ -531,10 +539,26 @@ actual fun ChatScreen( selectedModel = uiState.selectedModel, onModelSelect = { endpoint, model -> viewModel.onModelSelected(endpoint, model) - showModelSheet = false + // Clear any pending scaffold-level snackbar for the same error so it + // doesn't flash behind the sheet's close animation. Harmless no-op when + // error is already null. + viewModel.dismissError() + viewModel.dismissSendBlockReason() + viewModel.dismissModelSheet() + }, + onDismiss = { + viewModel.dismissError() + viewModel.dismissSendBlockReason() + viewModel.dismissModelSheet() }, - onDismiss = { showModelSheet = false }, serverUrl = uiState.serverUrl, + // Send-block reasons take precedence: when set, the sheet was auto-opened + // to help the user resolve the block, so surface that context inline. + errorMessage = sendBlockMessage ?: uiState.error, + onErrorDismiss = { + viewModel.dismissSendBlockReason() + viewModel.dismissError() + }, ) } diff --git a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt index 238e8c9..b357ef4 100644 --- a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt +++ b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/di/ConversationsModuleVerificationTest.kt @@ -6,6 +6,7 @@ import com.garfiec.librechat.core.data.datastore.ServerDataStore import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.MessageRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository @@ -25,6 +26,7 @@ class ConversationsModuleVerificationTest { ShareRepository::class, SearchRepository::class, ConfigRepository::class, + RoleRepository::class, ServerDataStore::class, ), ) diff --git a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt index 40a78a8..d4cdc48 100644 --- a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt +++ b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt @@ -2,6 +2,7 @@ package com.garfiec.librechat.feature.conversations.viewmodel import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.repository.ConversationRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository @@ -17,6 +18,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -39,6 +41,7 @@ class ConversationListViewModelTest { private val shareRepository = mockk(relaxed = true) private val conversationExporter = mockk(relaxed = true) private val conversationImporter = mockk(relaxed = true) + private val roleRepository = mockk(relaxed = true) private lateinit var viewModel: ConversationListViewModel @@ -73,6 +76,7 @@ class ConversationListViewModelTest { coEvery { conversationRepository.syncFavoritesFromServer() } returns Result.Success(Unit) every { tagRepository.observeTags() } returns flowOf(testTags) coEvery { tagRepository.refreshTags() } returns Result.Success(Unit) + every { roleRepository.userPermissions } returns MutableStateFlow(null) } @After @@ -87,6 +91,7 @@ class ConversationListViewModelTest { shareRepository = shareRepository, conversationExporter = conversationExporter, conversationImporter = conversationImporter, + roleRepository = roleRepository, ) @Test diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationActions.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationActions.kt index ca237f4..6121f28 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationActions.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationActions.kt @@ -57,6 +57,7 @@ fun ConversationActions( onBookmarkToggle: () -> Unit = {}, isBookmarked: Boolean = false, showShareAction: Boolean = false, + bookmarksEnabled: Boolean = true, ) { var showRenameDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } @@ -88,15 +89,17 @@ fun ConversationActions( }, ) - ActionRow( - icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, - label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark), - iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - onClick = { - onDismiss() - onBookmarkToggle() - }, - ) + if (bookmarksEnabled) { + ActionRow( + icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark), + iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + onClick = { + onDismiss() + onBookmarkToggle() + }, + ) + } ActionRow( icon = Icons.Default.BookmarkBorder, diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt index 3c5f4d6..b926605 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt @@ -41,6 +41,7 @@ fun ConversationItem( onClick: () -> Unit, onActionsClick: () -> Unit, modifier: Modifier = Modifier, + bookmarksEnabled: Boolean = true, ) { val relativeTime = remember(data.updatedAt) { data.updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "" @@ -134,7 +135,7 @@ fun ConversationItem( } } - if (data.isBookmarked) { + if (bookmarksEnabled && data.isBookmarked) { Icon( imageVector = Icons.Default.Bookmark, contentDescription = stringResource(Res.string.cd_bookmarked), diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt index e6c5de3..d977436 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/screen/ConversationListScreen.kt @@ -292,6 +292,7 @@ fun ConversationListScreen( displayData.conversationId, ) }, + bookmarksEnabled = uiState.bookmarksEnabled, ) HorizontalDivider( modifier = Modifier.padding(start = 52.dp), @@ -328,6 +329,7 @@ fun ConversationListScreen( conversation = selectedConversation!!, onDismiss = { selectedConversation = null }, isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()), + bookmarksEnabled = uiState.bookmarksEnabled, onBookmarkToggle = { selectedConversation?.let { convo -> viewModel.toggleFavorite(convo) diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt index 320d32d..160ab23 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt @@ -8,12 +8,16 @@ import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.repository.ConversationRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SearchRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.ConversationTag import com.garfiec.librechat.core.model.SAVED_TAG +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData import com.garfiec.librechat.feature.conversations.components.toDisplayData import com.garfiec.librechat.feature.conversations.export.ConversationExporter @@ -43,6 +47,8 @@ data class ConversationListUiState( val selectedTags: Set = emptySet(), val searchQuery: String = "", val isSearching: Boolean = false, + // Role-permission gate — default permissive until role loads. + val bookmarksEnabled: Boolean = true, ) sealed interface ConversationListEvent { @@ -60,6 +66,7 @@ class ConversationListViewModel( private val shareRepository: ShareRepository, private val conversationExporter: ConversationExporter, private val conversationImporter: ConversationImporter, + private val roleRepository: RoleRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ConversationListUiState()) @@ -76,6 +83,20 @@ class ConversationListViewModel( loadConversations() observeConversations() observeTags() + observePermissions() + } + + private fun observePermissions() { + viewModelScope.launch { + roleRepository.userPermissions.collect { role -> + _uiState.value = _uiState.value.copy( + bookmarksEnabled = role.hasAccessOrPermissive( + PermissionType.BOOKMARKS, + Permission.USE, + ), + ) + } + } } private fun observeConversations() { diff --git a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt index 0fc47d7..2a6bde8 100644 --- a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt @@ -13,9 +13,11 @@ import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MemoryRepository import com.garfiec.librechat.core.data.repository.PresetRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.SpeechRepository import com.garfiec.librechat.core.data.repository.UserRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.feature.settings.util.ContentReader import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner import com.garfiec.librechat.feature.settings.viewmodel.delegate.SpeechSettingsFactory @@ -34,6 +36,8 @@ class SettingsModuleVerificationTest { ConversationRepository::class, McpRepository::class, MemoryRepository::class, + RoleRepository::class, + PermissionGate::class, SpeechRepository::class, BalanceRepository::class, ShareRepository::class, diff --git a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt index 6df2e02..ad46f2d 100644 --- a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt @@ -14,9 +14,11 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MemoryRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.SpeechRepository import com.garfiec.librechat.core.data.repository.UserRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.User import com.garfiec.librechat.feature.settings.util.ContentReader import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner @@ -59,6 +61,8 @@ class SettingsViewModelTest { private val balanceRepository = mockk(relaxed = true) private val shareRepository = mockk(relaxed = true) private val keyRepository = mockk(relaxed = true) + private val roleRepository = mockk(relaxed = true) + private val permissionGate = mockk(relaxed = true) private val testUser = User( email = "test@example.com", @@ -107,6 +111,11 @@ class SettingsViewModelTest { coEvery { memoryRepository.getMemories() } returns Result.Success(emptyList()) coEvery { speechRepository.getVoices() } returns Result.Success(emptyList()) coEvery { balanceRepository.getBalance() } returns Result.Error(message = "Not available") + + // Permissive-null defaults so existing tests continue to exercise the + // same load paths via the `?: != false` idiom. + every { roleRepository.userPermissions } returns MutableStateFlow(null) + coEvery { permissionGate.awaitRole() } returns null } @After @@ -129,6 +138,8 @@ class SettingsViewModelTest { balanceRepository = balanceRepository, shareRepository = shareRepository, keyRepository = keyRepository, + roleRepository = roleRepository, + permissionGate = permissionGate, ) @Test diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/DataSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/DataSettingsScreen.kt index a6220b3..767a821 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/DataSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/DataSettingsScreen.kt @@ -146,26 +146,31 @@ fun DataSettingsContent( ) } - // Memories section - item(key = "memories_header") { - SectionHeader(stringResource(Res.string.section_memories)) - } - item(key = "memories_settings") { - MemoriesSettingsSection( - memories = uiState.memories, - memoriesEnabled = uiState.memoriesEnabled, - showMemoryDialog = uiState.showMemoryDialog, - editingMemory = uiState.editingMemory, - onToggleEnable = viewModel::toggleMemoriesEnabled, - onAddMemory = viewModel::showAddMemoryDialog, - onEditMemory = viewModel::showEditMemoryDialog, - onDeleteMemory = viewModel::deleteMemory, - onDismissDialog = viewModel::dismissMemoryDialog, - onSaveMemory = viewModel::saveMemory, - ) + // Memories section — hidden entirely when the server's MEMORIES.USE role + // permission is denied. The user-level opt-out (`memoriesEnabled`) stays + // independent and only hides the list inside this section. + if (uiState.serverMemoriesEnabled) { + item(key = "memories_header") { + SectionHeader(stringResource(Res.string.section_memories)) + } + item(key = "memories_settings") { + MemoriesSettingsSection( + memories = uiState.memories, + memoriesEnabled = uiState.memoriesEnabled, + showMemoryDialog = uiState.showMemoryDialog, + editingMemory = uiState.editingMemory, + onToggleEnable = viewModel::toggleMemoriesEnabled, + onAddMemory = viewModel::showAddMemoryDialog, + onEditMemory = viewModel::showEditMemoryDialog, + onDeleteMemory = viewModel::deleteMemory, + onDismissDialog = viewModel::dismissMemoryDialog, + onSaveMemory = viewModel::saveMemory, + ) + } } - // MCP section + // MCP section — always shown, but the section body degrades to + // "not available" and the "+ Add" button disappears when role denies. item(key = "mcp_header") { SectionHeader(stringResource(Res.string.section_mcp_servers)) } @@ -175,6 +180,8 @@ fun DataSettingsContent( connectionStatus = uiState.mcpConnectionStatus, reinitializingServers = uiState.mcpReinitializingServers, error = uiState.mcpError, + mcpServersEnabled = uiState.mcpServersEnabled, + mcpServersCreateEnabled = uiState.mcpServersCreateEnabled, onAddServer = viewModel::showAddMcpServerDialog, onEditServer = viewModel::showEditMcpServerDialog, onDeleteServer = viewModel::deleteMcpServer, diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/McpSettingsSection.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/McpSettingsSection.kt index 44a5255..2c7b71e 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/McpSettingsSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/McpSettingsSection.kt @@ -44,6 +44,8 @@ internal fun McpSettingsSection( modifier: Modifier = Modifier, reinitializingServers: Set = emptySet(), error: String? = null, + mcpServersEnabled: Boolean = true, + mcpServersCreateEnabled: Boolean = true, ) { Column(modifier = modifier) { Column( @@ -52,7 +54,13 @@ internal fun McpSettingsSection( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (error != null) { + if (!mcpServersEnabled) { + Text( + text = stringResource(Res.string.mcp_not_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (error != null) { Text( text = stringResource(Res.string.mcp_not_available), style = MaterialTheme.typography.bodyMedium, @@ -77,11 +85,14 @@ internal fun McpSettingsSection( } } - if (error == null) { + if (mcpServersEnabled && error == null) { Spacer(modifier = Modifier.height(4.dp)) } - if (error == null) { + // "+ Add MCP Server" stays behind both gates: + // USE must be allowed (otherwise the whole section is degraded) and + // CREATE must be allowed (separate sub-action). + if (mcpServersEnabled && error == null && mcpServersCreateEnabled) { OutlinedButton( onClick = onAddServer, modifier = Modifier.fillMaxWidth(), diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt index 0f14249..e89e208 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt @@ -18,8 +18,10 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MemoryRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.UserRepository +import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.model.Memory import com.garfiec.librechat.core.model.User import com.garfiec.librechat.core.model.mcp.McpApiKeyConfig @@ -27,6 +29,9 @@ import com.garfiec.librechat.core.model.mcp.McpOAuthConfig import com.garfiec.librechat.core.model.mcp.McpServer import com.garfiec.librechat.core.model.mcp.McpServerStatus import com.garfiec.librechat.core.model.mcp.McpServerType +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive import com.garfiec.librechat.core.model.speech.TtsVoice import com.garfiec.librechat.feature.settings.model.SharedLinkDisplayData import com.garfiec.librechat.feature.settings.model.UserDisplayData @@ -159,6 +164,14 @@ data class SettingsUiState( val showAvatars: Boolean = true, val showBubbles: Boolean = false, val latexRenderer: LatexRenderer = LatexRenderer.KATEX, + // Role-permission gates. `serverMemoriesEnabled` is the SERVER-level MEMORIES.USE + // gate and is orthogonal to [memoriesEnabled], which is the user's own opt-out + // stored on their profile (`user.personalization.memories`). + val mcpServersEnabled: Boolean = true, + val mcpServersCreateEnabled: Boolean = true, + val serverMemoriesEnabled: Boolean = true, + val remoteAgentsEnabled: Boolean = true, + val remoteAgentsCreateEnabled: Boolean = true, ) private fun User.toDisplayData() = UserDisplayData( @@ -194,6 +207,8 @@ class SettingsViewModel( private val balanceRepository: BalanceRepository, shareRepository: ShareRepository, keyRepository: KeyRepository, + private val roleRepository: RoleRepository, + private val permissionGate: PermissionGate, ) : ViewModel() { /** Raw state for everything not driven by DataStore flows. */ @@ -367,11 +382,48 @@ class SettingsViewModel( init { loadUser() - mcpDelegate.loadMcpServers() - memoryDelegate.loadMemories() speechDelegate.loadVoices() loadBalance() speechDelegate.loadDeviceVoices() + loadRoleGatedData() + observePermissionFlags() + } + + /** + * Gated loads share a single 5-second role-await budget so offline/timeout launches + * don't serialize into N×5s. `role?.hasAccess(...) != false` preserves permissive + * default: null role → true, missing type/action → true. + */ + private fun loadRoleGatedData() { + viewModelScope.launch { + val role = permissionGate.awaitRole() + if (role?.hasAccess(PermissionType.MCP_SERVERS, Permission.USE) != false) { + mcpDelegate.loadMcpServers() + } + if (role?.hasAccess(PermissionType.MEMORIES, Permission.USE) != false) { + memoryDelegate.loadMemories() + } + } + } + + /** + * Continuous collector — the 5 role-driven flags on SettingsUiState stay in sync + * with the current role. Permissive while role is null. + */ + private fun observePermissionFlags() { + viewModelScope.launch { + roleRepository.userPermissions.collect { role -> + _uiState.update { + it.copy( + mcpServersEnabled = role.hasAccessOrPermissive(PermissionType.MCP_SERVERS, Permission.USE), + mcpServersCreateEnabled = role.hasAccessOrPermissive(PermissionType.MCP_SERVERS, Permission.CREATE), + serverMemoriesEnabled = role.hasAccessOrPermissive(PermissionType.MEMORIES, Permission.USE), + remoteAgentsEnabled = role.hasAccessOrPermissive(PermissionType.REMOTE_AGENTS, Permission.USE), + remoteAgentsCreateEnabled = role.hasAccessOrPermissive(PermissionType.REMOTE_AGENTS, Permission.CREATE), + ) + } + } + } } // ── Account & user profile ───────────────────────────────────── diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt index 12b3647..231fcd1 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt @@ -219,8 +219,9 @@ fun DrawerContent( state = listState, modifier = Modifier.fillMaxSize(), ) { - // Favorites section - if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) { + // Favorites section — hidden entirely when BOOKMARKS.USE is denied so + // any locally-cached favorites from a prior permissive session don't leak. + if (uiState.bookmarksEnabled && uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) { item(key = "favorites_header") { Row( modifier = Modifier @@ -258,6 +259,7 @@ fun DrawerContent( data = data, onClick = { onConversationClick(data.conversationId) }, onToggleFavorite = { onToggleFavorite(data) }, + showBookmarkToggle = uiState.bookmarksEnabled, ) } @@ -304,6 +306,7 @@ fun DrawerContent( data = data, onClick = { onConversationClick(data.conversationId) }, onToggleFavorite = { onToggleFavorite(data) }, + showBookmarkToggle = uiState.bookmarksEnabled, ) } } @@ -332,11 +335,13 @@ fun DrawerContent( color = MaterialTheme.colorScheme.outlineVariant, ) - DrawerFooterItem( - icon = Icons.Default.SmartToy, - label = stringResource(Res.string.agents), - onClick = onAgentsClick, - ) + if (uiState.agentsEnabled) { + DrawerFooterItem( + icon = Icons.Default.SmartToy, + label = stringResource(Res.string.agents), + onClick = onAgentsClick, + ) + } DrawerFooterItem( icon = Icons.Default.Folder, label = stringResource(Res.string.files), @@ -358,6 +363,7 @@ private fun DrawerConversationItem( onClick: () -> Unit, modifier: Modifier = Modifier, onToggleFavorite: () -> Unit = {}, + showBookmarkToggle: Boolean = true, ) { val iconPainter = data.endpoint?.let { endpointIconPainter(it) } @@ -455,23 +461,25 @@ private fun DrawerConversationItem( } } - Icon( - imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, - contentDescription = if (data.isFavorite) { - stringResource(Res.string.remove_bookmark) - } else { - stringResource(Res.string.bookmark) - }, - modifier = Modifier - .size(32.dp) - .clickable(onClick = onToggleFavorite) - .padding(8.dp), - tint = if (data.isFavorite) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + if (showBookmarkToggle) { + Icon( + imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, + contentDescription = if (data.isFavorite) { + stringResource(Res.string.remove_bookmark) + } else { + stringResource(Res.string.bookmark) + }, + modifier = Modifier + .size(32.dp) + .clickable(onClick = onToggleFavorite) + .padding(8.dp), + tint = if (data.isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt index 90feaad..71e749c 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt @@ -30,4 +30,7 @@ data class DrawerUiState( val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, val hasMore: Boolean = true, + // Role-permission gates — default permissive. + val agentsEnabled: Boolean = true, + val bookmarksEnabled: Boolean = true, ) diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt index ce8a453..5c6747f 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt @@ -10,10 +10,15 @@ import com.garfiec.librechat.core.data.repository.AuthRepository import com.garfiec.librechat.core.data.repository.BannerRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository +import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.TagRepository +import com.garfiec.librechat.core.data.util.SessionTaskRunner import com.garfiec.librechat.core.model.Banner import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.SAVED_TAG +import com.garfiec.librechat.core.model.permissions.Permission +import com.garfiec.librechat.core.model.permissions.PermissionType +import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive import com.garfiec.librechat.core.network.client.TokenManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -30,6 +35,8 @@ class NavHostViewModel( bannerRepository: BannerRepository, configRepository: ConfigRepository, private val conversationRepository: ConversationRepository, + private val roleRepository: RoleRepository, + private val sessionTaskRunner: SessionTaskRunner, private val tagRepository: TagRepository, private val tokenManager: TokenManager, private val settingsDataStore: SettingsDataStore, @@ -78,6 +85,21 @@ class NavHostViewModel( _selectedSettingsCategory.value = category } + /** + * Role-permission flags for drawer UI. Permissive-default (`?: true`) until the + * role loads; once it does, denied permissions flip the flags off and the drawer + * re-composes to hide the corresponding surfaces. + */ + private val drawerPermissionFlags: StateFlow = + roleRepository.userPermissions + .map { role -> + DrawerPermissionFlags( + agentsEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.USE), + bookmarksEnabled = role.hasAccessOrPermissive(PermissionType.BOOKMARKS, Permission.USE), + ) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, DrawerPermissionFlags()) + val drawerUiState: StateFlow = combine( combine( conversationListStateHolder.groupedConversations, @@ -94,7 +116,8 @@ class NavHostViewModel( ) { refreshing, loadingMore, hasMore -> Triple(refreshing, loadingMore, hasMore) }, - ) { data, (refreshing, loadingMore, hasMore) -> + drawerPermissionFlags, + ) { data, (refreshing, loadingMore, hasMore), perms -> DrawerUiState( groupedConversations = data.grouped.map { (group, convos) -> group to convos.map { it.toDrawerDisplayData(data.activeId) } @@ -104,9 +127,16 @@ class NavHostViewModel( isRefreshing = refreshing, isLoadingMore = loadingMore, hasMore = hasMore, + agentsEnabled = perms.agentsEnabled, + bookmarksEnabled = perms.bookmarksEnabled, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) + private data class DrawerPermissionFlags( + val agentsEnabled: Boolean = true, + val bookmarksEnabled: Boolean = true, + ) + init { viewModelScope.launch { try { @@ -114,8 +144,10 @@ class NavHostViewModel( _isLoggedIn.value = loggedIn if (loggedIn) { versionCheckStateHolder.checkBackendVersion() - launch { tagRepository.refreshTags() } - launch { conversationRepository.syncFavoritesFromServer() } + // Session tasks for the cold-start case (role fetch, tag refresh, + // favorites sync). Runs on the application scope so tasks outlive + // this VM's scope. Fresh logins fire these from AuthRepositoryImpl. + sessionTaskRunner.runAll() } } catch (e: Exception) { Logger.w(e) { "Failed to check auth state on init" } @@ -131,10 +163,9 @@ class NavHostViewModel( fun onAuthComplete() { _isLoggedIn.value = true conversationListStateHolder.refreshConversations() - viewModelScope.launch { - launch { tagRepository.refreshTags() } - launch { conversationRepository.syncFavoritesFromServer() } - } + // Session tasks (role fetch, tag refresh, favorites sync) already fired from + // AuthRepositoryImpl on the preceding login/OAuth/2FA success, so we don't + // re-run them here — that was the source of the double-fetch on fresh login. bannerStateHolder.fetchBanners() versionCheckStateHolder.checkBackendVersion() } diff --git a/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt b/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt index 32d8720..e3bc4f3 100644 --- a/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt +++ b/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt @@ -19,6 +19,7 @@ import com.garfiec.librechat.core.network.api.MemoriesApi import com.garfiec.librechat.core.network.api.MessagesApi import com.garfiec.librechat.core.network.api.PresetsApi import com.garfiec.librechat.core.network.api.PromptsApi +import com.garfiec.librechat.core.network.api.RolesApi import com.garfiec.librechat.core.network.api.SearchApi import com.garfiec.librechat.core.network.api.ShareApi import com.garfiec.librechat.core.network.api.SpeechApi @@ -115,6 +116,7 @@ val iosSharedModule = module { singleOf(::AgentsApi) singleOf(::PresetsApi) singleOf(::PromptsApi) + singleOf(::RolesApi) singleOf(::TagsApi) singleOf(::ShareApi) singleOf(::SearchApi)