diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 2f81ca4d9..7feebf0ac 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -156,6 +156,33 @@ Stored Login Flow app passwords must not be treated as primary passwords for strict administrator confirmation. The client must not collect or retain a primary account password to bypass that boundary. +### Administration visibility + +`AdministrationAccessRepository` owns session-scoped, in-memory permission +evidence from the existing read-only administrator catalog contract. It reuses +that catalog for the Server apps screen and caches both allowed and denied +results for five minutes using a monotonic clock. Opening Settings checks the +cache; while the root Settings screen or Server apps is visible, expired evidence +is refreshed. Opening a child screen from Settings stops automatic polling. +Polling also stops when Android leaves the started lifecycle or the desktop +window is hidden or minimized. Returning to a visible screen reuses fresh +evidence or revalidates expired evidence. Settings retains the requested section +while Administration is hidden during a check, without rendering its controls. +No permission result survives logout, account replacement, or process restart. +Both catalog request paths use `ForceNetwork` so revalidation cannot renew access +from the transport's persisted response cache. + +Administration, its installed-workspace summary, and the Server apps catalog +require fresh successful evidence. Unknown, expired, denied, malformed, and +unavailable results hide those surfaces, including restored navigation. The +Server apps route keeps a loading or typed failure view with retry and Back +instead of navigating away silently when permission revalidation fails. An +explicit refresh removes old access before requesting new evidence. Concurrent +checks share the cached result, and cancellation cannot publish a late success. +The ordinary Apps workspace remains available to regular users. Cached visibility +never authorizes a mutation; strict operations still use authenticated browser +handoff and server-side authorization. + ## Error and cancellation rules Errors must retain enough structured context to support recovery and safe diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/MainActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/MainActivity.kt index e72970704..365c48315 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/MainActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -18,12 +19,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalConfiguration +import dev.obiente.nextcloudnative.app.LocalAppWindowVisibility +import kotlinx.coroutines.flow.MutableStateFlow import dev.obiente.nextcloudnative.app.NextcloudNativeApp import dev.obiente.nextcloudnative.app.NextcloudNativeLinkRequest import dev.obiente.nextcloudnative.app.ThemePreference import java.util.UUID class MainActivity : ComponentActivity() { + private val windowVisibility = MutableStateFlow(false) private var appUpdateReviewRequest by mutableLongStateOf(0L) private var lastAppUpdateReviewEventId: Long? = null private var platformCapabilityRefreshRequest by mutableLongStateOf(0L) @@ -131,24 +135,36 @@ class MainActivity : ComponentActivity() { window.decorView.setBackgroundColor(background.toArgb()) } - NextcloudNativeApp( - services = services, - appUpdateReviewRequest = appUpdateReviewRequest, - platformCapabilityRefreshRequest = platformCapabilityRefreshRequest, - linkRequest = incomingLinkRequests.firstOrNull(), - onLinkRequestHandled = { sequence -> - if (incomingLinkRequests.firstOrNull()?.sequence == sequence) { - incomingLinkRequests.removeAt(0) - } - }, - linkQueueOverflowEvent = incomingLinkQueueOverflowEvent, - onLinkQueueOverflowHandled = { event -> - if (incomingLinkQueueOverflowEvent == event) incomingLinkQueueOverflowEvent = 0L - }, - ) + CompositionLocalProvider(LocalAppWindowVisibility provides windowVisibility) { + NextcloudNativeApp( + services = services, + appUpdateReviewRequest = appUpdateReviewRequest, + platformCapabilityRefreshRequest = platformCapabilityRefreshRequest, + linkRequest = incomingLinkRequests.firstOrNull(), + onLinkRequestHandled = { sequence -> + if (incomingLinkRequests.firstOrNull()?.sequence == sequence) { + incomingLinkRequests.removeAt(0) + } + }, + linkQueueOverflowEvent = incomingLinkQueueOverflowEvent, + onLinkQueueOverflowHandled = { event -> + if (incomingLinkQueueOverflowEvent == event) incomingLinkQueueOverflowEvent = 0L + }, + ) + } } } + override fun onStart() { + super.onStart() + windowVisibility.value = true + } + + override fun onStop() { + windowVisibility.value = false + super.onStop() + } + override fun onResume() { super.onResume() platformCapabilityRefreshRequest += 1 diff --git a/changes/unreleased/cached-administration-permissions.md b/changes/unreleased/cached-administration-permissions.md new file mode 100644 index 000000000..c1beaa998 --- /dev/null +++ b/changes/unreleased/cached-administration-permissions.md @@ -0,0 +1,7 @@ +category: fix +issue: 187 +pull: none +platforms: android, desktop +user-facing: yes + +Hide Administration and Server apps unless the signed-in account has freshly verified access. Cache permission checks for five minutes within the session, and prevent stale responses or restored navigation from exposing admin controls. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 66d03f066..a1c411f47 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -23,7 +23,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12313 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12200 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1716 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt index ef6e2d7ec..216128d21 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt @@ -27,6 +27,7 @@ internal fun SettingsScreen( themePreference: ThemePreference, platformCapabilityRefreshRequest: Long, onThemePreferenceChanged: (ThemePreference) -> Unit, + canAdminister: Boolean, onAdminApps: () -> Unit, onOfflineCenter: () -> Unit, onTransfers: () -> Unit, @@ -35,9 +36,8 @@ internal fun SettingsScreen( ) { val scope = rememberCoroutineScope() val isDesktop = LocalNextcloudWorkspaceCapabilities.current.isDesktop - var selectedSectionName by rememberSaveable(session.serverUrl, session.loginName) { - mutableStateOf(null) - } + val selection = rememberSettingsSectionSelection(session.serverUrl, session.loginName) + var selectedSectionName by selection val supportDrafts = rememberAccountSupportSettingsDraftState(session) val detailStateHolder = rememberSaveableStateHolder() var loggingOut by remember { mutableStateOf(false) } @@ -61,6 +61,7 @@ internal fun SettingsScreen( isDesktop = isDesktop, hasDeviceSettings = platformCapabilities.isNotEmpty(), hasDesktopAppSettings = hasDesktopAppSettings, + canAdminister = canAdminister, ) val selectedSection = selectedSectionName?.let { restoredName -> resolveSettingsWorkspaceSection(restoredName, visibleSections) @@ -263,11 +264,7 @@ internal fun SettingsScreen( BoxWithConstraints { val expanded = useExpandedSettingsWorkspace(maxWidth.value.toInt()) val displayedSection = if (expanded) expandedSettingsSection(selectedSection, visibleSections) else selectedSection - LaunchedEffect(expanded, displayedSection) { - if (expanded && displayedSection != null && selectedSectionName != displayedSection.name) { - selectedSectionName = displayedSection.name - } - } + InitializeSettingsSectionSelection(selection, expanded, displayedSection) if (expanded) { DesktopSettingsWorkspace( summary = SettingsWorkspaceSummary( @@ -315,3 +312,21 @@ internal fun expandedSettingsSection( selectedSection: SettingsWorkspaceSection?, visibleSections: List, ): SettingsWorkspaceSection? = selectedSection ?: visibleSections.firstOrNull() + +@Composable +internal fun rememberSettingsSectionSelection(serverUrl: String, loginName: String) = + rememberSaveable(serverUrl, loginName) { mutableStateOf(null) } + +@Composable +internal fun InitializeSettingsSectionSelection( + selection: androidx.compose.runtime.MutableState, + expanded: Boolean, + displayedSection: SettingsWorkspaceSection?, +) { + LaunchedEffect(expanded, displayedSection, selection.value) { + // A hidden section is a display fallback, not a new user selection. + if (expanded && displayedSection != null && selection.value == null) { + selection.value = displayedSection.name + } + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt index fd7ec88fe..5760f2949 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt @@ -104,6 +104,7 @@ suspend fun loadNativeAppCatalog( queryParameters = mapOf("details" to "true", "format" to "json"), ocsApiRequest = true, maximumResponseBytes = APP_CATALOG_RESPONSE_LIMIT_BYTES, + cachePolicy = NextcloudApiCachePolicy.ForceNetwork, ), ) when (appStoreResponse.status) { @@ -309,6 +310,7 @@ private fun legacyAppListRequest(filter: String): NextcloudApiRequest = Nextclou relativePath = PROVISIONING_APPS_PATH, queryParameters = mapOf("filter" to filter, "format" to "json"), ocsApiRequest = true, + cachePolicy = NextcloudApiCachePolicy.ForceNetwork, ) private fun appStoreMutationRequest( @@ -379,9 +381,9 @@ private fun NextcloudApiResponse.ocsDataOrNull(): JsonElement? { val ocs = root["ocs"] as? JsonObject ?: return null val meta = ocs["meta"] as? JsonObject ?: return null val statusCode = (meta["statuscode"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() - if (statusCode != null && statusCode !in setOf(100, 200)) return null + if (statusCode !in setOf(100, 200)) return null val statusValue = (meta["status"] as? JsonPrimitive)?.contentOrNull - if (statusValue != null && statusValue != "ok") return null + if (statusValue != "ok") return null return ocs["data"] } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreen.kt new file mode 100644 index 000000000..98676a5dd --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreen.kt @@ -0,0 +1,131 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +internal sealed interface AdminAppsContent { + data object Checking : AdminAppsContent + data class Catalog(val catalog: NativeAppCatalog) : AdminAppsContent + enum class Failure(val message: String) : AdminAppsContent { + Forbidden("This account does not have permission to manage server apps. You can return to Settings."), + Unavailable("Administrator access could not be verified. Try again, or return to Settings."), + InvalidResponse("The server returned an unexpected response while checking administrator access. Try again, or return to Settings."), + Expired("Administrator access needs to be checked again before server apps can be shown."), + } +} + +internal fun adminAppsContent(state: AdministrationAccessState, canAdminister: Boolean): AdminAppsContent { + if (state.checking) return AdminAppsContent.Checking + return when (val result = state.result) { + null -> AdminAppsContent.Checking + is NativeAppCatalogResult.Available -> if (canAdminister && state.canAdminister) { + AdminAppsContent.Catalog(result.catalog) + } else { + AdminAppsContent.Failure.Expired + } + NativeAppCatalogResult.Forbidden -> AdminAppsContent.Failure.Forbidden + NativeAppCatalogResult.Unavailable -> AdminAppsContent.Failure.Unavailable + is NativeAppCatalogResult.InvalidResponse -> AdminAppsContent.Failure.InvalidResponse + } +} + +@Composable +internal fun AdminAppsScreen( + access: AdministrationAccessController, + onContinueInBrowser: () -> Unit, + serverInfo: NextcloudServerInfo?, + onOpenApp: (NextcloudAppEntry) -> Unit, + onBack: () -> Unit, +) { + var search by remember { mutableStateOf("") } + var catalogFilter by remember { mutableStateOf(NativeAppCatalogFilter.All) } + var pendingLifecycleAction by remember(access.state.result) { + mutableStateOf?>(null) + } + val content = adminAppsContent(access.state, access.canAdminister) + + Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { + ScreenHeader( + title = "Server apps", + subtitle = "Administrator app management", + onBack = onBack, + ) + when (content) { + AdminAppsContent.Checking -> LoadingMessage("Checking administrator access...") + is AdminAppsContent.Catalog -> NativeAppCatalogSurface( + catalog = content.catalog, + query = search, + filter = catalogFilter, + onQueryChanged = { search = it }, + onFilterChanged = { catalogFilter = it }, + onOpenInstalledApp = { managed -> + serverInfo?.apps?.firstOrNull { app -> app.id == managed.id }?.let(onOpenApp) + }, + onLifecycleAction = { app, action -> + if (access.canAdminister) pendingLifecycleAction = app to action + }, + ) + is AdminAppsContent.Failure -> ErrorMessage( + content.message, + onRetry = access.refresh, + ) + } + } + + pendingLifecycleAction?.takeIf { content is AdminAppsContent.Catalog }?.let { (app, action) -> + AlertDialog( + onDismissRequest = { pendingLifecycleAction = null }, + title = { Text("${action.uiLabel()} ${app.name}?") }, + text = { + Text( + when (action) { + NativeAppLifecycleAction.InstallAndEnable -> + "This downloads server-side code and enables the app for users." + NativeAppLifecycleAction.Enable -> + "This activates the app and may add navigation, jobs, and integrations for users." + NativeAppLifecycleAction.Disable -> + "This makes the app and its integrations unavailable until an administrator enables it again." + NativeAppLifecycleAction.Update -> + "The server may enter maintenance mode while the app package is updated. Do not interrupt it." + NativeAppLifecycleAction.Uninstall -> + "This removes the app package. App data retention depends on the app and is not guaranteed." + } + "\n\nNextcloud requires administrator password confirmation. Continue in the authenticated server administration page.", + ) + }, + dismissButton = { + TextButton(onClick = { pendingLifecycleAction = null }) { Text("Cancel") } + }, + confirmButton = { + Button( + onClick = { + pendingLifecycleAction = null + if (access.canAdminister) { + onContinueInBrowser() + access.refresh() + } + }, + colors = if (action == NativeAppLifecycleAction.Uninstall) { + ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + } else { + ButtonDefaults.buttonColors() + }, + ) { + Text("Continue in browser") + } + }, + ) + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessComposition.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessComposition.kt new file mode 100644 index 000000000..bb3abf4f3 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessComposition.kt @@ -0,0 +1,60 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.rememberUpdatedState +import dev.obiente.nextcloudnative.app.design.NextcloudDestination +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** The platform host reports whether its application window is visible. */ +val LocalAppWindowVisibility = staticCompositionLocalOf> { MutableStateFlow(false) } + +internal fun administrationAccessPollingActive(screen: Screen, destination: NextcloudDestination): Boolean = + screen == Screen.AdminApps || (screen == Screen.Root && destination == NextcloudDestination.Settings) + +internal class AdministrationAccessController( + val state: AdministrationAccessState, + private val permissionCheck: () -> Boolean, + val refresh: () -> Unit, +) { + val canAdminister: Boolean get() = permissionCheck() +} + +@Composable +internal fun rememberAdministrationAccess( + session: NextcloudSession, + refreshRequest: Long, + active: Boolean, + execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, +): AdministrationAccessController = key(session) { + val repository = remember { AdministrationAccessRepository() } + val currentExecute by rememberUpdatedState(execute) + val state by repository.state.collectAsState() + val scope = rememberCoroutineScope() + DisposableEffect(repository) { onDispose { repository.retire() } } + val visibility = LocalAppWindowVisibility.current + LaunchedEffect(repository, refreshRequest, active, visibility) { + if (active) visibility.collectLatest { visible -> + while (visible && isActive) { + repository.refresh { request -> currentExecute(request) } + val age = repository.state.value.checkedAt?.elapsedNow() ?: AdministrationAccessTtl + delay((AdministrationAccessTtl - age).coerceAtLeast(kotlin.time.Duration.ZERO)) + } + } + } + AdministrationAccessController(state, permissionCheck = { repository.state.value.canAdminister }) { + scope.launch { repository.refresh(force = true) { currentExecute(it) } } + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepository.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepository.kt new file mode 100644 index 000000000..7491ff6ec --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepository.kt @@ -0,0 +1,66 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +internal data class AdministrationAccessState( + val result: NativeAppCatalogResult? = null, + val checking: Boolean = false, + val checkedAt: TimeMark? = null, + val retired: Boolean = false, +) { + val canAdminister: Boolean + get() = isFresh && (result as? NativeAppCatalogResult.Available)?.catalog?.administratorAuthorized == true + + val isFresh: Boolean + get() = checkedAt?.elapsedNow()?.let { it >= kotlin.time.Duration.ZERO && it < AdministrationAccessTtl } == true +} + +internal val AdministrationAccessTtl = 5.minutes + +/** + * One instance per authenticated session. Nothing is persisted or shared between accounts. + * The verified read-only catalog doubles as permission evidence and the management screen cache. + */ +internal class AdministrationAccessRepository(private val timeSource: TimeSource = TimeSource.Monotonic) { + private val mutableState = MutableStateFlow(AdministrationAccessState()) + val state = mutableState.asStateFlow() + private val mutex = Mutex() + + fun retire() { + mutableState.value = AdministrationAccessState(retired = true) + } + + suspend fun refresh( + force: Boolean = false, + execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, + ) = mutex.withLock { + val previous = mutableState.value + if (previous.retired || (!force && previous.isFresh)) return@withLock + // Expired or explicitly refreshed permission must not leave old admin controls actionable. + val checking = AdministrationAccessState(checking = true) + if (!mutableState.compareAndSet(previous, checking)) return@withLock + try { + val result = loadNativeAppCatalog(execute) + currentCoroutineContext().ensureActive() + mutableState.compareAndSet(checking, AdministrationAccessState(result = result, checkedAt = timeSource.markNow())) + } catch (cancelled: CancellationException) { + mutableState.compareAndSet(checking, AdministrationAccessState()) + throw cancelled + } catch (_: Exception) { + // Transport fault boundary: no account data or exception text enters the UI/cache. + mutableState.compareAndSet(checking, AdministrationAccessState( + result = NativeAppCatalogResult.Unavailable, + checkedAt = timeSource.markNow(), + )) + } + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 881a3f103..25e5bf747 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1219,6 +1219,10 @@ private fun AuthenticatedApp( session.loginName, stateSaver = enumSaver(), ) { mutableStateOf(NextcloudDestination.Home) } + val administration = rememberAdministrationAccess( + session, platformCapabilityRefreshRequest, + active = administrationAccessPollingActive(screen, destination), + ) { services.executeNextcloudApi(session, it) } var serverInfo by remember(session) { mutableStateOf(null) } var lastOpenedAppId by remember(session) { mutableStateOf(services.loadLastOpenedAppId()) } val appPinsStorage = rememberAccountHomeWorkspaceStorage(session) @@ -2151,7 +2155,8 @@ private fun AuthenticatedApp( themePreference = themePreference, platformCapabilityRefreshRequest = platformCapabilityRefreshRequest, onThemePreferenceChanged = onThemePreferenceChanged, - onAdminApps = { screen = Screen.AdminApps }, + canAdminister = administration.canAdminister, + onAdminApps = { if (administration.canAdminister) screen = Screen.AdminApps }, onOfflineCenter = { if (presentation == NextcloudPresentation.Desktop) { screen = Screen.Root @@ -2323,8 +2328,10 @@ private fun AuthenticatedApp( modifier = Modifier.fillMaxSize().safeDrawingPadding(), ) Screen.AdminApps -> AdminAppsScreen( - services = services, - session = session, + access = administration, + onContinueInBrowser = { + services.openExternalUrl(session.serverUrl.trimEnd('/') + "/index.php/settings/apps") + }, serverInfo = serverInfo, onOpenApp = { openApp(it, NextcloudDestination.Settings) }, onBack = ::navigateBack, @@ -2682,126 +2689,6 @@ private fun AppsScreen( ) } -@Composable -private fun AdminAppsScreen( - services: NextcloudPlatformServices, - session: NextcloudSession, - serverInfo: NextcloudServerInfo?, - onOpenApp: (NextcloudAppEntry) -> Unit, - onBack: () -> Unit, -) { - var search by remember { mutableStateOf("") } - var catalogFilter by remember { mutableStateOf(NativeAppCatalogFilter.All) } - var catalogResult by remember(session) { mutableStateOf(null) } - var catalogAttempt by remember(session) { mutableStateOf(0) } - var catalogRefreshing by remember(session) { mutableStateOf(false) } - var catalogRefreshError by remember(session) { mutableStateOf(null) } - var pendingLifecycleAction by remember { - mutableStateOf?>(null) - } - LaunchedEffect(session, catalogAttempt) { - val retained = catalogResult - catalogRefreshing = retained != null - catalogRefreshError = null - val loaded = runCatching { - loadNativeAppCatalog { request -> services.executeNextcloudApi(session, request) } - }.getOrElse { - NativeAppCatalogResult.InvalidResponse("The administrator app catalog could not be loaded.") - } - if (retained is NativeAppCatalogResult.Available && loaded !is NativeAppCatalogResult.Available) { - catalogRefreshError = when (loaded) { - NativeAppCatalogResult.Forbidden -> "This account no longer has permission to refresh server apps." - NativeAppCatalogResult.Unavailable -> "Administrator app management is currently unavailable." - is NativeAppCatalogResult.InvalidResponse -> loaded.reason - is NativeAppCatalogResult.Available -> error("Handled above") - } - } else { - catalogResult = loaded - } - catalogRefreshing = false - } - - Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { - ScreenHeader( - title = "Server apps", - subtitle = "Administrator app management", - onBack = onBack, - ) - if (catalogRefreshing) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - catalogRefreshError?.let { message -> - RetainedRefreshError(message = message, onRetry = { catalogAttempt += 1 }) - } - when (val result = catalogResult) { - null -> LoadingMessage("Loading administrator app catalog...") - is NativeAppCatalogResult.Available -> NativeAppCatalogSurface( - catalog = result.catalog, - query = search, - filter = catalogFilter, - onQueryChanged = { search = it }, - onFilterChanged = { catalogFilter = it }, - onOpenInstalledApp = { managed -> - serverInfo?.apps?.firstOrNull { app -> app.id == managed.id }?.let(onOpenApp) - }, - onLifecycleAction = { app, action -> pendingLifecycleAction = app to action }, - ) - NativeAppCatalogResult.Forbidden -> ErrorMessage( - "This account does not have permission to manage server apps.", - onRetry = { catalogAttempt += 1 }, - ) - NativeAppCatalogResult.Unavailable -> ErrorMessage( - "Administrator app management is unavailable on this server.", - onRetry = { catalogAttempt += 1 }, - ) - is NativeAppCatalogResult.InvalidResponse -> ErrorMessage( - result.reason, - onRetry = { catalogAttempt += 1 }, - ) - } - } - - pendingLifecycleAction?.let { (app, action) -> - AlertDialog( - onDismissRequest = { pendingLifecycleAction = null }, - title = { Text("${action.uiLabel()} ${app.name}?") }, - text = { - Text( - when (action) { - NativeAppLifecycleAction.InstallAndEnable -> - "This downloads server-side code and enables the app for users." - NativeAppLifecycleAction.Enable -> - "This activates the app and may add navigation, jobs, and integrations for users." - NativeAppLifecycleAction.Disable -> - "This makes the app and its integrations unavailable until an administrator enables it again." - NativeAppLifecycleAction.Update -> - "The server may enter maintenance mode while the app package is updated. Do not interrupt it." - NativeAppLifecycleAction.Uninstall -> - "This removes the app package. App data retention depends on the app and is not guaranteed." - } + "\n\nNextcloud requires administrator password confirmation. Continue in the authenticated server administration page.", - ) - }, - dismissButton = { - TextButton(onClick = { pendingLifecycleAction = null }) { Text("Cancel") } - }, - confirmButton = { - Button( - onClick = { - pendingLifecycleAction = null - services.openExternalUrl(session.serverUrl.trimEnd('/') + "/index.php/settings/apps") - catalogAttempt += 1 - }, - colors = if (action == NativeAppLifecycleAction.Uninstall) { - ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - } else { - ButtonDefaults.buttonColors() - }, - ) { - Text("Continue in browser") - } - }, - ) - } -} - @Composable private fun AppInfoScreen( services: NextcloudPlatformServices, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt index 67703cd46..2b0849f52 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt @@ -73,8 +73,10 @@ internal fun visibleSettingsSections( isDesktop: Boolean, hasDeviceSettings: Boolean, hasDesktopAppSettings: Boolean = isDesktop, + canAdminister: Boolean = false, ): List = SettingsWorkspaceSection.entries.filter { section -> when (section) { + SettingsWorkspaceSection.Administration -> canAdminister SettingsWorkspaceSection.DesktopApp -> isDesktop && hasDesktopAppSettings SettingsWorkspaceSection.NotificationsAndDevice -> hasDeviceSettings else -> true diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdminAppsContentTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdminAppsContentTest.kt new file mode 100644 index 000000000..393cb1969 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdminAppsContentTest.kt @@ -0,0 +1,47 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TestTimeSource + +class AdminAppsContentTest { + @Test + fun failuresRemainVisibleWithoutExposingTheCatalog() { + val cases = listOf( + NativeAppCatalogResult.Forbidden to AdminAppsContent.Failure.Forbidden, + NativeAppCatalogResult.Unavailable to AdminAppsContent.Failure.Unavailable, + NativeAppCatalogResult.InvalidResponse("Synthetic invalid response") to AdminAppsContent.Failure.InvalidResponse, + ) + for ((result, expected) in cases) { + assertEquals(expected, adminAppsContent(AdministrationAccessState(result = result), canAdminister = false)) + } + } + + @Test + fun checkingAndRestoredUnknownStateNeverShowCatalogControls() { + assertEquals(AdminAppsContent.Checking, adminAppsContent(AdministrationAccessState(), false)) + assertEquals( + AdminAppsContent.Checking, + adminAppsContent(AdministrationAccessState(checking = true), false), + ) + } + + @Test + fun expiredOrRevokedLiveAccessCannotRenderACachedCatalog() { + val clock = TestTimeSource() + val state = AdministrationAccessState( + result = NativeAppCatalogResult.Available(NativeAppCatalog( + contract = NativeAppCatalogContract.ProvisioningOcsV1, + apps = emptyList(), administratorAuthorized = true, + includesAvailableApps = false, includesUpdateAvailability = false, + )), + checkedAt = clock.markNow(), + ) + assertIs(adminAppsContent(state, true)) + assertEquals(AdminAppsContent.Failure.Expired, adminAppsContent(state, false)) + clock += 5.minutes + assertEquals(AdminAppsContent.Failure.Expired, adminAppsContent(state, true)) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepositoryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepositoryTest.kt new file mode 100644 index 000000000..03bc05e7a --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepositoryTest.kt @@ -0,0 +1,197 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TestTimeSource + +class AdministrationAccessRepositoryTest { + @Test + fun unknownAndOtherAccountsNeverInheritAccess(): Unit = runBlocking { + val admin = AdministrationAccessRepository() + val regular = AdministrationAccessRepository() + assertFalse(admin.state.value.canAdminister) + admin.refresh { allowedResponse() } + assertTrue(admin.state.value.canAdminister) + assertFalse(regular.state.value.canAdminister) + regular.refresh { response(status = 403) } + assertFalse(regular.state.value.canAdminister) + assertTrue(admin.state.value.canAdminister) + } + + @Test + fun successfulCatalogIsReusedUntilItsExactExpiry(): Unit = runBlocking { + val clock = TestTimeSource() + val repository = AdministrationAccessRepository(clock) + var calls = 0 + val execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse = { request -> + assertEquals(NextcloudApiMethod.GET, request.method) + assertEquals(NextcloudApiCachePolicy.ForceNetwork, request.cachePolicy) + calls++ + allowedResponse() + } + repository.refresh(execute = execute) + repeat(3) { repository.refresh(execute = execute) } + assertEquals(1, calls) + clock += 5.minutes + assertFalse(repository.state.value.canAdminister) + repository.refresh(execute = execute) + assertEquals(2, calls) + assertTrue(repository.state.value.canAdminister) + } + + @Test + fun legacyPermissionEvidenceAlsoBypassesTransportCaches(): Unit = runBlocking { + val repository = AdministrationAccessRepository() + var calls = 0 + repository.refresh { request -> + assertEquals(NextcloudApiMethod.GET, request.method) + assertEquals(NextcloudApiCachePolicy.ForceNetwork, request.cachePolicy) + calls++ + if (calls == 1) response(status = 404) else response( + body = """{"ocs":{"meta":{"status":"ok","statuscode":100},"data":{"apps":[]}}}""", + ) + } + assertEquals(3, calls) + assertTrue(repository.state.value.canAdminister) + } + + @Test + fun deniedResultsAreCachedAndPromotionIsDiscoveredAfterExpiry(): Unit = runBlocking { + val clock = TestTimeSource() + val repository = AdministrationAccessRepository(clock) + var calls = 0 + repeat(3) { + repository.refresh { calls++; response(status = 403) } + } + assertEquals(1, calls) + assertFalse(repository.state.value.canAdminister) + clock += 5.minutes + repository.refresh { allowedResponse() } + assertTrue(repository.state.value.canAdminister) + } + + @Test + fun expiredOrExplicitlyRefreshedAccessIsHiddenBeforeNetworkCompletion(): Unit = runBlocking { + for (force in listOf(false, true)) { + val clock = TestTimeSource() + val repository = AdministrationAccessRepository(clock) + repository.refresh { allowedResponse() } + if (!force) clock += 5.minutes + val pending = CompletableDeferred() + val request = async(start = CoroutineStart.UNDISPATCHED) { + repository.refresh(force) { pending.await() } + } + assertTrue(repository.state.value.checking) + assertFalse(repository.state.value.canAdminister) + pending.complete(response(status = 403)) + request.await() + assertIs(repository.state.value.result) + assertFalse(repository.state.value.canAdminister) + } + } + + @Test + fun concurrentChecksPerformOneRead(): Unit = runBlocking { + val repository = AdministrationAccessRepository() + val pending = CompletableDeferred() + var calls = 0 + val first = async(start = CoroutineStart.UNDISPATCHED) { + repository.refresh { calls++; pending.await() } + } + val second = async(start = CoroutineStart.UNDISPATCHED) { + repository.refresh { calls++; allowedResponse() } + } + pending.complete(allowedResponse()) + first.await() + second.await() + assertEquals(1, calls) + assertTrue(repository.state.value.canAdminister) + } + + @Test + fun cancellationCannotBecomeCachedSuccessEvenIfTransportSwallowsIt(): Unit = runBlocking { + val repository = AdministrationAccessRepository() + val pending = CompletableDeferred() + val job = launch(start = CoroutineStart.UNDISPATCHED) { + repository.refresh { + try { + pending.await() + } catch (_: CancellationException) { + // Simulate an incorrectly cancellation-swallowing platform boundary. + } + allowedResponse() + } + } + job.cancelAndJoin() + assertFalse(repository.state.value.canAdminister) + assertFalse(repository.state.value.checking) + assertNull(repository.state.value.result) + repository.refresh { response(status = 403) } + assertIs(repository.state.value.result) + } + + @Test + fun retiredAccountRejectsLateCompletionAndFurtherChecks(): Unit = runBlocking { + val repository = AdministrationAccessRepository() + val pending = CompletableDeferred() + val request = async(start = CoroutineStart.UNDISPATCHED) { + repository.refresh { pending.await() } + } + repository.retire() + pending.complete(allowedResponse()) + request.await() + repository.refresh { error("Retired accounts must not make requests") } + assertTrue(repository.state.value.retired) + assertFalse(repository.state.value.canAdminister) + assertNull(repository.state.value.result) + } + + @Test + fun failedRefreshNeverRetainsEarlierPermission(): Unit = runBlocking { + val repository = AdministrationAccessRepository() + repository.refresh { allowedResponse() } + repository.refresh(force = true) { error("Synthetic transport failure") } + assertFalse(repository.state.value.canAdminister) + assertIs(repository.state.value.result) + } + + @Test + fun deniedUnavailableAndMalformedCatalogsCannotGrantAccess(): Unit = runBlocking { + val responses = listOf( + response(status = 401), response(status = 403), response(status = 503), + response(body = "not json"), response(body = "{}"), + response(body = """{"ocs":{"meta":{},"data":[]}}"""), + response(body = """{"ocs":{"meta":{"status":"failure","statuscode":403},"data":[]}}"""), + response(body = """{"ocs":{"meta":{"status":"ok","statuscode":200},"data":{}}}"""), + allowedResponse().copy(contentType = "text/html"), + ) + for (response in responses) { + val repository = AdministrationAccessRepository() + repository.refresh { response } + assertFalse(repository.state.value.canAdminister) + } + } + + private fun allowedResponse() = response( + body = """{"ocs":{"meta":{"status":"ok","statuscode":200},"data":[]}}""", + ) + + private fun response(status: Int = 200, body: String = "{}") = NextcloudApiResponse( + status = status, + body = body.encodeToByteArray(), + contentType = "application/json", + etag = null, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationNavigationTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationNavigationTest.kt new file mode 100644 index 000000000..d5d55418f --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationNavigationTest.kt @@ -0,0 +1,32 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.app.design.NextcloudDestination +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AdministrationNavigationTest { + @Test + fun onlyVisibleSettingsOrServerAppsPollPermissions() { + for (destination in NextcloudDestination.entries) { + assertEquals( + destination == NextcloudDestination.Settings, + administrationAccessPollingActive(Screen.Root, destination), + ) + assertTrue(administrationAccessPollingActive(Screen.AdminApps, destination)) + } + } + + @Test + fun settingsChildScreensDoNotPollWithRetainedSettingsDestination() { + val screens = listOf( + Screen.OfflineCenter, Screen.Transfers, Screen.ProjectNews, + Screen.Files(""), Screen.Media, Screen.Notes, + Screen.AppInfo(NextcloudAppEntry("sample_app", "Sample app", null)), + ) + for (screen in screens) { + assertFalse(administrationAccessPollingActive(screen, NextcloudDestination.Settings)) + } + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspaceTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspaceTest.kt index 222c7c390..d04b4c6b1 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspaceTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspaceTest.kt @@ -7,6 +7,22 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class SettingsWorkspaceTest { + @Test + fun administrationRequiresPermissionOnBothLayouts() { + for (desktop in listOf(false, true)) { + val regular = visibleSettingsSections(isDesktop = desktop, hasDeviceSettings = true) + assertFalse(SettingsWorkspaceSection.Administration in regular) + val admin = visibleSettingsSections( + isDesktop = desktop, hasDeviceSettings = true, canAdminister = true, + ) + assertTrue(SettingsWorkspaceSection.Administration in admin) + assertEquals( + SettingsWorkspaceSection.Account, + resolveSettingsWorkspaceSection("Administration", regular), + ) + } + } + @Test fun `width below 600 uses compact category and detail flow`() { val compact = resolveSettingsWorkspaceLayout(599) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt index 692661658..fc5359acf 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt @@ -1,5 +1,9 @@ package dev.obiente.nextcloudnative.nativeui.preview +import androidx.compose.runtime.CompositionLocalProvider +import kotlinx.coroutines.flow.MutableStateFlow +import dev.obiente.nextcloudnative.app.LocalAppWindowVisibility + import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -370,11 +374,14 @@ private fun launchDesktopProcess(arguments: Array) { if (mainWindow.value === window) mainWindow.value = null } } + val administrationWindowVisibility = remember { MutableStateFlow(false) } SideEffect { + administrationWindowVisibility.value = windowVisible.value && !mainWindowState.isMinimized applyDesktopNativeWindowFrame(window, darkTheme) window.background = java.awt.Color(background.toArgb(), true) window.minimumSize = java.awt.Dimension(960, 640) } + CompositionLocalProvider(LocalAppWindowVisibility provides administrationWindowVisibility) { Box(Modifier.fillMaxSize().background(background)) { NextcloudNativeApp( services = services, @@ -387,6 +394,7 @@ private fun launchDesktopProcess(arguments: Array) { }, ) } + } } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreenRecoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreenRecoveryTest.kt new file mode 100644 index 000000000..79d40b16d --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreenRecoveryTest.kt @@ -0,0 +1,75 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.CompositionLocalProvider +import dev.obiente.nextcloudnative.app.design.LocalNextcloudWorkspaceCapabilities +import dev.obiente.nextcloudnative.app.design.NextcloudWorkspaceCapabilities +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.TimeSource + +class AdminAppsScreenRecoveryTest { + @Test + fun failedRevalidationKeepsRecoveryControlsWithoutNavigatingAway() { + val failures = listOf( + NativeAppCatalogResult.Forbidden to AdminAppsContent.Failure.Forbidden, + NativeAppCatalogResult.Unavailable to AdminAppsContent.Failure.Unavailable, + NativeAppCatalogResult.InvalidResponse("Synthetic invalid response") to AdminAppsContent.Failure.InvalidResponse, + ) + for ((width, height) in listOf(390 to 844, 1280 to 800)) { + for ((result, expected) in failures) { + val allowed = AdministrationAccessState( + result = NativeAppCatalogResult.Available(NativeAppCatalog( + contract = NativeAppCatalogContract.ProvisioningOcsV1, + apps = listOf(NativeManagedApp("sample_app", "Sample app", installed = true, enabled = true)), + administratorAuthorized = true, + includesAvailableApps = false, includesUpdateAvailability = false, + )), + checkedAt = TimeSource.Monotonic.markNow(), + ) + val state = mutableStateOf(allowed) + var backCalls = 0 + var retries = 0 + val desktop = width >= 900 + nativeSceneTest(width, height, content = { + CompositionLocalProvider(LocalNextcloudWorkspaceCapabilities provides + NextcloudWorkspaceCapabilities(desktop, desktop, desktop) + ) { + AdminAppsScreen( + access = AdministrationAccessController( + state.value, permissionCheck = { state.value.canAdminister }, refresh = { retries++ }, + ), + onContinueInBrowser = { error("A failed check must never open administration") }, + serverInfo = null, + onOpenApp = {}, + onBack = { backCalls++ }, + ) + } + }) { + assertTrue(has("Sample app")) + state.value = AdministrationAccessState(checking = true) + settle() + assertFalse(has("Sample app")) + assertEquals(0, backCalls) + state.value = AdministrationAccessState(result = result) + settle() + assertTrue(has(expected.message)) + assertFalse(has("Sample app")) + assertFalse(has("Find an app")) + assertEquals(0, backCalls) + capture("admin-revalidation-$width-${expected.name.lowercase()}") + click("Back") + assertEquals(1, backCalls) + click("Try again") + assertEquals(1, retries) + state.value = allowed + settle() + assertTrue(has("Sample app")) + assertEquals(1, backCalls) + } + } + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessCompositionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessCompositionTest.kt new file mode 100644 index 000000000..abaf8bb96 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessCompositionTest.kt @@ -0,0 +1,119 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AdministrationAccessCompositionTest { + @Test + fun settingsReusesCacheAndAccountSwitchNeverRendersPreviousPermission() = runBlocking { + val frameClock = BroadcastFrameClock() + val recomposer = Recomposer(coroutineContext + frameClock) + val recomposerJob = launch(frameClock, start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(EmptyUnitApplier(), recomposer) + var frameTime = 0L + val frames = launch { + while (isActive) { + Snapshot.sendApplyNotifications() + frameClock.sendFrame(frameTime++) + delay(1) + } + } + suspend fun advance() = withTimeout(5_000) { + yield() + Snapshot.sendApplyNotifications() + recomposer.awaitIdle() + } + var session by mutableStateOf(NextcloudSession( + serverUrl = "https://permissions.example.test", loginName = "admin", appPassword = "synthetic", + )) + var active by mutableStateOf(true) + var denyAdmin = false + var calls = 0 + var rendered: AdministrationAccessController? = null + val visibility = MutableStateFlow(true) + val regularRenders = mutableListOf() + try { + composition.setContent { + CompositionLocalProvider(LocalAppWindowVisibility provides visibility) { + val account = session.loginName + val access = rememberAdministrationAccess(session, refreshRequest = 0, active = active) { + calls++ + NextcloudApiResponse( + status = if (account == "admin" && !denyAdmin) 200 else 403, + contentType = "application/json", etag = null, + body = """{"ocs":{"meta":{"status":"ok","statuscode":200},"data":[]}}""".encodeToByteArray(), + ) + } + rendered = access + if (account == "regular") regularRenders += access.state.canAdminister + } + } + withTimeout(5_000) { + while (rendered?.state?.canAdminister != true) advance() + } + assertEquals(1, calls) + active = false + advance() + active = true + advance() + assertEquals(1, calls) + assertTrue(requireNotNull(rendered).state.canAdminister) + + denyAdmin = true + val staleController = requireNotNull(rendered) + requireNotNull(rendered).refresh() + withTimeout(5_000) { + while (rendered?.state?.result != NativeAppCatalogResult.Forbidden) advance() + } + assertFalse(requireNotNull(rendered).state.canAdminister) + assertFalse(staleController.canAdminister) + + denyAdmin = false + requireNotNull(rendered).refresh() + withTimeout(5_000) { + while (rendered?.state?.canAdminister != true) advance() + } + session = session.copy(loginName = "regular") + withTimeout(5_000) { + while (regularRenders.isEmpty() || rendered?.state?.result != NativeAppCatalogResult.Forbidden) advance() + } + assertTrue(regularRenders.isNotEmpty()) + assertTrue(regularRenders.none { it }) + } finally { + frames.cancelAndJoin() + composition.dispose() + recomposer.cancel() + recomposerJob.cancelAndJoin() + } + } + + private class EmptyUnitApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationSettingsSelectionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationSettingsSelectionTest.kt new file mode 100644 index 000000000..c2d499e94 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationSettingsSelectionTest.kt @@ -0,0 +1,74 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import kotlin.test.Test +import kotlin.test.assertEquals + +class AdministrationSettingsSelectionTest { + @Test + fun restoredAdministrationSelectionSurvivesInitialPermissionCheck() { + var registry = SaveableStateRegistry(null) { true } + val visible = mutableStateOf(listOf(SettingsWorkspaceSection.Account, SettingsWorkspaceSection.Administration)) + var requested: MutableState? = null + var displayed: SettingsWorkspaceSection? = null + val content: @Composable () -> Unit = { + CompositionLocalProvider(LocalSaveableStateRegistry provides registry) { + val selection = rememberSettingsSectionSelection("https://permissions.example.test", "sample") + requested = selection + displayed = resolveSettingsWorkspaceSection(selection.value, visible.value) + InitializeSettingsSectionSelection(selection, expanded = true, displayed) + } + } + nativeSceneTest(1280, 800, content = content) { + requested!!.value = SettingsWorkspaceSection.Administration.name + settle() + registry = SaveableStateRegistry(registry.performSave()) { true } + } + visible.value = listOf(SettingsWorkspaceSection.Account) + nativeSceneTest(1280, 800, content = content) { + assertEquals(SettingsWorkspaceSection.Account, displayed) + assertEquals(SettingsWorkspaceSection.Administration.name, requested!!.value) + visible.value = listOf(SettingsWorkspaceSection.Account, SettingsWorkspaceSection.Administration) + settle() + assertEquals(SettingsWorkspaceSection.Administration, displayed) + } + } + + @Test + fun temporarilyHiddenAdministrationDoesNotReplaceRequestedSelection() { + val allowed = listOf(SettingsWorkspaceSection.Account, SettingsWorkspaceSection.Administration) + val visible = mutableStateOf(allowed) + var requested: MutableState? = null + var displayed: SettingsWorkspaceSection? = null + nativeSceneTest(1280, 800, content = { + val selection = rememberSettingsSectionSelection("https://permissions.example.test", "sample") + requested = selection + displayed = resolveSettingsWorkspaceSection(selection.value, visible.value) + InitializeSettingsSectionSelection(selection, expanded = true, displayed) + }) { + assertEquals(SettingsWorkspaceSection.Account.name, requested!!.value) + requested!!.value = SettingsWorkspaceSection.Administration.name + settle() + assertEquals(SettingsWorkspaceSection.Administration, displayed) + visible.value = listOf(SettingsWorkspaceSection.Account) + settle() + assertEquals(SettingsWorkspaceSection.Account, displayed) + assertEquals(SettingsWorkspaceSection.Administration.name, requested!!.value) + visible.value = allowed + settle() + assertEquals(SettingsWorkspaceSection.Administration, displayed) + visible.value = listOf(SettingsWorkspaceSection.Account) + settle() + requested!!.value = SettingsWorkspaceSection.Account.name + settle() + visible.value = allowed + settle() + assertEquals(SettingsWorkspaceSection.Account, displayed) + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationVisibilityTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationVisibilityTest.kt new file mode 100644 index 000000000..514c111f5 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AdministrationVisibilityTest.kt @@ -0,0 +1,40 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.CompositionLocalProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.awaitCancellation +import kotlin.test.Test +import kotlin.test.assertEquals + +class AdministrationVisibilityTest { + @Test + fun hiddenWindowStopsAndCancelsPollingAndResumeRestartsIt() { + val visible = MutableStateFlow(false) + val session = NextcloudSession("https://permissions.example.test", "sample", "synthetic") + var calls = 0 + var cancelled = 0 + nativeSceneTest(390, 844, content = { + CompositionLocalProvider(LocalAppWindowVisibility provides visible) { + rememberAdministrationAccess(session, 0, active = true) { + calls++ + try { awaitCancellation() } finally { cancelled++ } + } + } + }) { + assertEquals(0, calls) + visible.value = true + settle() + assertEquals(1, calls) + visible.value = false + settle() + assertEquals(1, cancelled) + assertEquals(1, calls) + visible.value = true + settle() + assertEquals(2, calls) + visible.value = false + settle() + assertEquals(2, cancelled) + } + } +} diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index b9efda13a..19cc6411b 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -22,6 +22,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreen.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessComposition.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepository.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppCoroutineResult.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppNavigationState.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateChannels.kt", @@ -448,13 +451,16 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountHomeWorkspaceStorage.kt": "5e0fa144df65840d15882c7da1dac19041083a302ced40578d07747f8ec7d828", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "35c13216a14662f9bc2b47cee36b29f9d7d8602373b937536fcc5cc01da67e4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt": "09053be8a8739289395cd079d9eff473df53f5eb87305bb6609f4dabefe86bb2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt": "f59c5befeabd5986b2a12cdaa62bbe71faaebd52c007616fc7a1e7d43046c5ea", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt": "dbd8a8e84812769affd73c83d10a7ac5cab125408dba75020c5ed81836c554ac", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt": "b987eb0ae9d1c30ecbda65a753aaf469b836394aca42c68c63e406a067a8a653", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt": "569265895b9442292c043f5ecbe2cdd55a9da6761a77b19b5f81fff34e999a10", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityHistoryPresentation.kt": "88f25cd079f7d7fc1553f8969818740816e2788542b70377ea11f64201c44474", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt": "625e281281f28e5a2d0497626efc882f4fb2b5e778fcbdcac34425c853f83730", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt": "79a2ba91cee7ed17441f4b0bcc781eb7bfa8c5eb90a1717233df96794c56a61c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt": "b11fbc2c93ab17a20f123c8bde25a993c612ffb1b1525329a7e341952fefbf7d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt": "a82f5b173075a98e0c3bd544b90de08c1d3dc291a0e7329ce00822aa0584abd3", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppsScreen.kt": "22c6a8d16faca6d0c3c94273dd05ffad27f9592805c9daf2e79dbab9a141d229", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessComposition.kt": "ce7427c7e00de99cf5bee3782601ce1a53e09ff39ab215ee76a17d6599806680", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdministrationAccessRepository.kt": "c9343041d0ce159bd97494fa7036c98d8140b089decd0d84b63dd5bf2a35a1a3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppCoroutineResult.kt": "a0d6f140ffa50d1def77e0734b4ceae63855eed9766e2f3f82e82f2307ddc8f7", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppNavigationState.kt": "115b9a0b5d360fae4f0738d9e16e1077d969ca4bb5d3c136401d7156adde431c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateChannels.kt": "f8aef5ec39978ef0d80ff6cbab00a9a4af34d73c2f3a759eb483a5cadfb8170a", @@ -660,7 +666,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "4f2c26b7f76b9edd9af33da676f83298a02cb571cd10f4bc1f41a73a42eb5e47", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "db61c541cebc01d632557d71dec7dd9d1b3b5fa65f8533add7610d675a44669c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "96c1edf4c68256a6e0e33ccd2ab0c7d1ed3fb512eba0444334a07b2da711488e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", @@ -713,7 +719,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RetainedContentNotice.kt": "77bcf477e60d7e3022abea07ada109c92e57b4a3c2dd57e61de7bdf53d242b4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ServerCertificateReviewDialog.kt": "f7c4d2489596788486893ed746eb762060b5080f64dae5659eca8b48feabf0ea", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsSectionContent.kt": "3ce0895751bc569843ffabfcdae6ca11c68b4c700a954cb5e1370391cd87e472", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt": "040e1226c31e0e04b8a0fb13c2d85305d62c218eb08c1a23cd6be9fa11d7bd29", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt": "377027a31cba2a1d1dd5e90490b7c26e6e8cd068c793835e159effc3f65c507d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "ae9e2daa635332327b296ec5bce8da09ef32aa5249b6b1f8e029b73fa224f31a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnosticsDisclosure.kt": "248523efc8721ef405f5f000ba3c518db5d6ed4d5129cbbd418b431f19e29ed1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnosticsSettings.kt": "f851adbdd10d8549f4ad1678c192430bac3e27f12fe89e26c430e66bf0b42ae9", @@ -849,7 +855,7 @@ "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt": "7402f5b4bf3bbf3cf20761eaa10eca9131fb6e63da2988d0aa6038942d706c53", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncTrayVisualQaMain.kt": "4ce39ce3a53b72a32ca7489ef65cf301e9d8279f4be9ef9afc3ace701075c301", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncWorkspaceVisualQaMain.kt": "af79af05554e41806121b49f7fe8e31a67127636f0faec78f44ac812726c86e0", - "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt": "b6f26394074b06186397d381dc7b20dc4690c36fd37865df5134377188520936", + "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt": "ab84dc18e58bb410e9bbf48371ee74319601caa1356366f3ab5e35ccae01bb3b", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureMain.kt": "bd7f6b8870c0030f061c5e466849b597f3388df6e9b4c5a6987000bb0dc0fbdf", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt": "c5bd9b63793a8884cf6b74cadf3061dc0ac295a982c97ae7f0a906ac3ae6f3c9", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt": "6970352c6e42d09b793c55ea296991d756d232f216d8d152aa00319b2ddb8d20", @@ -2394,7 +2400,7 @@ "purpose": "state-coverage", "platform": "desktop", "viewport": "wide", - "sha256": "316347fb81c3bfd5db96facb92fd3708ed7445beeaaef077cfc5fee2f2f94e2c" + "sha256": "3d10e6ccc9f92a6262d0364bf26aecd98a911236bf1e1130d634079bc16ed30e" }, { "scenario": "desktop-startup-settings-light", @@ -2410,7 +2416,7 @@ "purpose": "state-coverage", "platform": "desktop", "viewport": "wide", - "sha256": "853c1bbad08d3c3b7a8fdd9409950ceb88b35af5576af442f67145d28ae9267e" + "sha256": "c21f4553b8afc45a75d34aeb948da843d09658750d85728efbd003330d7af4c9" }, { "scenario": "tables-insights-desktop", @@ -4062,7 +4068,7 @@ "purpose": "showcase", "platform": "desktop", "viewport": "wide", - "sha256": "316347fb81c3bfd5db96facb92fd3708ed7445beeaaef077cfc5fee2f2f94e2c" + "sha256": "3d10e6ccc9f92a6262d0364bf26aecd98a911236bf1e1130d634079bc16ed30e" }, { "scenario": "guide-desktop-getting-started-settings-light", @@ -4078,7 +4084,7 @@ "purpose": "showcase", "platform": "desktop", "viewport": "wide", - "sha256": "853c1bbad08d3c3b7a8fdd9409950ceb88b35af5576af442f67145d28ae9267e" + "sha256": "c21f4553b8afc45a75d34aeb948da843d09658750d85728efbd003330d7af4c9" }, { "scenario": "guide-android-offline-files-browse", diff --git a/website/public/screenshots/desktop-startup-settings-light.png b/website/public/screenshots/desktop-startup-settings-light.png index 1b80d9e63..44ea306ab 100644 Binary files a/website/public/screenshots/desktop-startup-settings-light.png and b/website/public/screenshots/desktop-startup-settings-light.png differ diff --git a/website/public/screenshots/desktop-startup-settings.png b/website/public/screenshots/desktop-startup-settings.png index f37413272..c22898368 100644 Binary files a/website/public/screenshots/desktop-startup-settings.png and b/website/public/screenshots/desktop-startup-settings.png differ diff --git a/website/public/screenshots/guide-desktop-getting-started-settings-light.png b/website/public/screenshots/guide-desktop-getting-started-settings-light.png index 1b80d9e63..44ea306ab 100644 Binary files a/website/public/screenshots/guide-desktop-getting-started-settings-light.png and b/website/public/screenshots/guide-desktop-getting-started-settings-light.png differ diff --git a/website/public/screenshots/guide-desktop-getting-started-settings.png b/website/public/screenshots/guide-desktop-getting-started-settings.png index f37413272..c22898368 100644 Binary files a/website/public/screenshots/guide-desktop-getting-started-settings.png and b/website/public/screenshots/guide-desktop-getting-started-settings.png differ