From bb9cd9008d0e5a58b242b8ad170308020cfd8080 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:36:53 -0500 Subject: [PATCH] fix(nav): gate Moderation and Support queue on the shard module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the M20 trace, and fixed in this PR by the org lead's decision. Both rows call /admin/shard/*, module-uo's admin mount, but they carried no capability. So a moderator on a Rust-only site saw "Moderation" in the drawer and opened a UO form ("Serial (0x…)", kick or ban by UO account) where every action failed. Both rows now declare Capability.SHARD, as the player rows do, so they are UO screens and their strings belong in strings_uo.xml, where step 1 put them. A new MenuCapabilityGatingTest case covers it: a moderator on a Rust-only backend sees neither row but keeps the Dashboard, and on a UO backend sees both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../runicgateway/app/ui/navigation/Menu.kt | 17 ++++++++++++++-- .../ui/navigation/MenuCapabilityGatingTest.kt | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index a8f7c8d..a3bb18e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -196,8 +196,21 @@ val APP_MENU: List = listOf( // Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF), MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), - MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR), - MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR), + // Moderation and the support queue act on the shard (`/admin/shard/*`, module-uo's + // admin mount), so they need the module installed like the player rows do. On a + // Rust-only site they once showed a UO form whose every action failed (M20). + MenuEntry( + Routes.ADMIN_MODERATION, + R.string.menu_admin_moderation, + MenuAccess.MODERATOR, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.ADMIN_SUPPORT, + R.string.menu_admin_support, + MenuAccess.MODERATOR, + capability = Capability.SHARD, + ), ) /** diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt index 99df01a..6e42726 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt @@ -168,6 +168,26 @@ class MenuCapabilityGatingTest { } } + @Test fun moderationAndSupportNeedTheShardModule() { + // Both call `/admin/shard/*`, module-uo's admin mount. Before M20 they + // carried no capability, so a moderator on a Rust-only site saw a UO form + // whose every action failed. + val staffRows = APP_MENU.filter { it.route == Routes.ADMIN_MODERATION || it.route == Routes.ADMIN_SUPPORT } + assertEquals(2, staffRows.size) + assertTrue(staffRows.all { it.capability == Capability.SHARD }) + + val moderator = signedIn(Role.MODERATOR) + val rustOnly = visibleEntries(APP_MENU, moderator, null, serving(Capability.RUST)).map { it.route } + assertFalse(Routes.ADMIN_MODERATION in rustOnly) + assertFalse(Routes.ADMIN_SUPPORT in rustOnly) + // …while the core staff rows stay. + assertTrue(Routes.ADMIN_DASHBOARD in rustOnly) + + val uo = visibleEntries(APP_MENU, moderator, null, serving(Capability.SHARD)).map { it.route } + assertTrue(Routes.ADMIN_MODERATION in uo) + assertTrue(Routes.ADMIN_SUPPORT in uo) + } + @Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() { // Events are core's. A `feature` on one of them would gate a core screen on // a module's visibility config, which is the coupling this separation exists