From ac99a012b0985c4c715be2ffadd36f47afd413cc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 21 Jul 2026 15:37:05 -0500 Subject: [PATCH] feat(admin): staff nav + dashboard/site-mode (M10 Phase 3, part 1) Add the staff-operations surface scaffolding and the first group. Session gains isStaff/isModerator/isAdmin; the menu gains STAFF (admin/editor/moderator) and MODERATOR (admin/moderator) access levels, plus a StaffGate mirroring PlayerGate. Dashboard group (over the existing /api/v1/admin, bearer-authed, role re-checked every request): AdminApi/AdminDto/AdminRepository for GET /admin/dashboard and PUT /admin/site-mode; AdminDashboardScreen shows site mode, summary counts, and recent admin activity, with an admin-only maintenance/live toggle. Verified on emulator against the dev backend: an admin sees the Dashboard entry (a player does not); counts + audit log render from real data; the site-mode toggle flips /public/status to maintenance and back to live. MenuAccessTest +2 (8 total), assembleDebug + lint green. Co-Authored-By: Claude --- .../com/runicgateway/app/core/auth/Session.kt | 9 + .../com/runicgateway/app/data/api/AdminApi.kt | 31 ++++ .../runicgateway/app/data/api/dto/AdminDto.kt | 58 ++++++ .../app/data/repository/AdminRepository.kt | 30 ++++ .../com/runicgateway/app/di/NetworkModule.kt | 6 + .../java/com/runicgateway/app/ui/RunicApp.kt | 27 +++ .../app/ui/admin/AdminDashboardScreen.kt | 169 ++++++++++++++++++ .../app/ui/admin/AdminDashboardViewModel.kt | 91 ++++++++++ .../runicgateway/app/ui/navigation/Menu.kt | 10 ++ .../runicgateway/app/ui/navigation/Routes.kt | 7 + app/src/main/res/values/strings.xml | 19 ++ .../app/ui/navigation/MenuAccessTest.kt | 20 +++ 12 files changed, 477 insertions(+) create mode 100644 app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt diff --git a/app/src/main/java/com/runicgateway/app/core/auth/Session.kt b/app/src/main/java/com/runicgateway/app/core/auth/Session.kt index a2f5f58..da93185 100644 --- a/app/src/main/java/com/runicgateway/app/core/auth/Session.kt +++ b/app/src/main/java/com/runicgateway/app/core/auth/Session.kt @@ -16,6 +16,15 @@ data class SessionUser( val role: Role, ) { val isPlayer: Boolean get() = role == Role.PLAYER + + /** Any staff role (moderator/editor/admin) — the staff-operations surface (§1, M10). */ + val isStaff: Boolean get() = role.isStaff + + /** Admin or moderator — moderation actions + the support queue (`modAccess`). */ + val isModerator: Boolean get() = role == Role.ADMIN || role == Role.MODERATOR + + /** Admin only — site-mode and other `adminOnly` controls. */ + val isAdmin: Boolean get() = role == Role.ADMIN } /** diff --git a/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt new file mode 100644 index 0000000..05005b1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api + +import com.runicgateway.app.data.api.dto.AdminDashboardDto +import com.runicgateway.app.data.api.dto.SiteModeRequest +import com.runicgateway.app.data.api.dto.SiteModeStateDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PUT + +/** + * The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On + * the authed client — every call carries the bearer, and the backend re-checks the + * caller's role on every request (`staffOnly` / `modAccess` / `adminOnly`), so a + * demoted user is refused server-side even if a stale menu still showed the entry. + * + * Grows one group at a time (dashboard first); moderation, support, and content + * endpoints are added with their screens. + */ +interface AdminApi { + + /** `GET /admin/dashboard` — summary counts + site mode (any staff role). */ + @GET("api/v1/admin/dashboard") + suspend fun dashboard(): AdminDashboardDto + + /** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */ + @PUT("api/v1/admin/site-mode") + suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt new file mode 100644 index 0000000..6a352e1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Wire shapes for the M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md + * §1, §6.4). These are consumed only by the staff screens (dashboard, moderation, + * support, content); every DTO ignores unknown keys (NetworkModule's lenient Json) + * so additive backend fields stay safe. Nothing here is auto-provisioned or secret. + */ + +/** `GET /admin/dashboard` — the staff landing summary. */ +@Serializable +data class AdminDashboardDto( + @SerialName("site_mode") val siteMode: String = "live", + @SerialName("last_change") val lastChange: SiteModeChangeDto = SiteModeChangeDto(), + val counts: AdminCountsDto = AdminCountsDto(), + @SerialName("recent_activity") val recentActivity: List = emptyList(), +) + +@Serializable +data class SiteModeChangeDto( + val at: String? = null, + val by: String? = null, +) + +@Serializable +data class AdminCountsDto( + /** Post counts keyed by DB category (`news`, `five_on_friday`, …). */ + val posts: Map = emptyMap(), + val users: Int = 0, +) + +/** One row of the recent admin-activity log. `detail` is provider-shaped JSON. */ +@Serializable +data class AdminActivityDto( + val id: Long = 0, + val username: String? = null, + val action: String = "", + val detail: JsonElement? = null, + @SerialName("created_at") val createdAt: String? = null, +) + +/** `PUT /admin/site-mode` request + response. */ +@Serializable +data class SiteModeRequest(val mode: String) + +@Serializable +data class SiteModeStateDto( + @SerialName("site_mode") val siteMode: String = "live", + @SerialName("changed_at") val changedAt: String? = null, + @SerialName("changed_by") val changedBy: String? = null, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt new file mode 100644 index 0000000..554ccd0 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt @@ -0,0 +1,30 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.safeApiCall +import com.runicgateway.app.data.api.AdminApi +import com.runicgateway.app.data.api.dto.AdminDashboardDto +import com.runicgateway.app.data.api.dto.SiteModeRequest +import com.runicgateway.app.data.api.dto.SiteModeStateDto +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The M10 staff-operations data source over `/api/v1/admin/…` (PLAN.md §1, §6.4). + * Every call returns a typed [ApiResult] so a screen renders a clean error/retry + * rather than crashing — a `403` (role lost since the menu rendered) and a `503` + * (shard/sidecar offline for the shard-write actions) are both expected outcomes + * the UI handles, never thrown. Role is authoritative on the server. + */ +@Singleton +class AdminRepository @Inject constructor( + private val api: AdminApi, +) { + suspend fun dashboard(): ApiResult = safeApiCall { api.dashboard() } + + suspend fun setSiteMode(mode: String): ApiResult = + safeApiCall { api.setSiteMode(SiteModeRequest(mode)) } +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 015f5ca..8a0a8a2 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthRefreshApi import com.runicgateway.app.data.api.MeApi +import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.NotificationsApi import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PublicApi @@ -119,6 +120,11 @@ object NetworkModule { fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi = retrofit.create(NotificationsApi::class.java) + /** Staff operations (§1, §6.4, M10) — bearer-authed; the server re-checks role every call. */ + @Provides + @Singleton + fun provideAdminApi(retrofit: Retrofit): AdminApi = retrofit.create(AdminApi::class.java) + /** * Token refresh runs on its own **bare** client — UA + host retargeting only, * no auth interceptor and no authenticator — so a refresh can never recurse diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 7538240..15fd102 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -59,6 +59,7 @@ import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen +import com.runicgateway.app.ui.admin.AdminDashboardScreen import com.runicgateway.app.ui.notifications.NotificationsScreen import com.runicgateway.app.ui.page.PageScreen import com.runicgateway.app.ui.player.CharacterSheetScreen @@ -81,6 +82,7 @@ private val TOP_LEVEL_ROUTES = setOf( Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, Routes.NOTIFICATIONS, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, + Routes.ADMIN_DASHBOARD, ) /** @@ -361,6 +363,15 @@ private fun RunicNavHost( composable(Routes.PLAYER_HOUSES) { PlayerGate(session, navController) { MyHousesScreen() } } + + // ── Staff operations (§1, §6.4, M10) — reached from the staff menu section. + // The backend re-checks role on every /admin/… call; these gates only mirror + // the menu's visibility so a signed-out/demoted user isn't left on a stale screen. + composable(Routes.ADMIN_DASHBOARD) { + StaffGate(session, navController) { + AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true) + } + } } } @@ -382,6 +393,22 @@ private fun PlayerGate( } } +/** + * The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for + * a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is + * sent home rather than left on a stale admin screen. The backend is the authority — + * every `/admin/…` call re-checks role — so this only mirrors the menu's visibility. + */ +@Composable +private fun StaffGate( + session: Session, + navController: NavHostController, + content: @Composable () -> Unit, +) { + val isStaff = (session as? Session.SignedIn)?.user?.isStaff == true + if (isStaff) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } +} + /** Navigate to a top-level menu destination: single instance, reset to it. */ private fun NavHostController.navigateTopLevel(route: String) { navigate(route) { diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt new file mode 100644 index 0000000..da6f15d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt @@ -0,0 +1,169 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.AdminDashboardDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.StatusPill + +/** + * The staff dashboard (PLAN.md §1, M10): site mode + a site-mode toggle (admins + * only), summary counts, and recent admin activity. Read-only for moderators/editors; + * only [isAdmin] callers see the maintenance switch, and the server enforces it too. + */ +@Composable +fun AdminDashboardScreen( + isAdmin: Boolean, + modifier: Modifier = Modifier, + viewModel: AdminDashboardViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val ds = state.dashboard) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(ds.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Success -> DashboardContent( + data = ds.data, + isAdmin = isAdmin, + switching = state.switching, + feedbackRes = state.feedback?.messageRes, + onSetMode = viewModel::setSiteMode, + modifier = modifier, + ) + } +} + +@Composable +private fun DashboardContent( + data: AdminDashboardDto, + isAdmin: Boolean, + switching: Boolean, + feedbackRes: Int?, + onSetMode: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val live = data.siteMode.equals("live", ignoreCase = true) + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + ) { + // ── Site status ────────────────────────────────────────────── + SectionLabel(stringResource(R.string.admin_dashboard_site)) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + StatusPill( + text = if (live) stringResource(R.string.admin_site_live) else stringResource(R.string.admin_site_maintenance), + tone = if (live) PillTone.Success else PillTone.Warning, + ) + data.lastChange.by?.takeIf { it.isNotBlank() }?.let { by -> + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.admin_site_changed_by, by), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (isAdmin) { + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onSetMode(if (live) "maintenance" else "live") }, + enabled = !switching, + modifier = Modifier.fillMaxWidth(), + ) { + if (switching) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp)) + } else { + Text( + stringResource( + if (live) R.string.admin_site_switch_maintenance else R.string.admin_site_switch_live, + ), + ) + } + } + } + + feedbackRes?.let { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Counts ─────────────────────────────────────────────────── + Spacer(Modifier.height(24.dp)) + SectionLabel(stringResource(R.string.admin_dashboard_counts)) + Spacer(Modifier.height(8.dp)) + StatRow(stringResource(R.string.admin_count_users), data.counts.users.toString()) + val totalPosts = data.counts.posts.values.sum() + StatRow(stringResource(R.string.admin_count_posts), totalPosts.toString()) + data.counts.posts.forEach { (category, count) -> + StatRow("· $category", count.toString()) + } + + // ── Recent activity ────────────────────────────────────────── + if (data.recentActivity.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + SectionLabel(stringResource(R.string.admin_dashboard_recent_activity)) + Spacer(Modifier.height(8.dp)) + data.recentActivity.forEach { row -> + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text(row.action, style = MaterialTheme.typography.bodyMedium) + val meta = listOfNotNull(row.username, row.createdAt).joinToString(" · ") + if (meta.isNotBlank()) { + Text( + text = meta, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun StatRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt new file mode 100644 index 0000000..2f19f53 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt @@ -0,0 +1,91 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.R +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.api.dto.AdminDashboardDto +import com.runicgateway.app.data.repository.AdminRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Drives the staff dashboard (PLAN.md §1, M10): summary counts + the site-mode + * toggle. The mode switch is admin-only server-side (`adminOnly`); the screen only + * offers it to admins, but a `403` is still handled cleanly if a moderator reaches + * it. Everything is read through the typed [AdminRepository] (§7). + */ +@HiltViewModel +class AdminDashboardViewModel @Inject constructor( + private val admin: AdminRepository, +) : ViewModel() { + + data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int) + + data class State( + val dashboard: UiState = UiState.Loading, + /** True while a site-mode switch is in flight (disables the control). */ + val switching: Boolean = false, + val feedback: Feedback? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.update { it.copy(dashboard = UiState.Loading) } + viewModelScope.launch { + _state.update { it.copy(dashboard = admin.dashboard().toUiState()) } + } + } + + fun clearFeedback() = _state.update { it.copy(feedback = null) } + + /** Switch the site between "live" and "maintenance" (admin only). */ + fun setSiteMode(mode: String) { + if (_state.value.switching) return + _state.update { it.copy(switching = true, feedback = null) } + viewModelScope.launch { + when (val result = admin.setSiteMode(mode)) { + is ApiResult.Ok -> { + // Reflect the new mode locally, then refresh the full summary. + val current = _state.value.dashboard + if (current is UiState.Success) { + _state.update { + it.copy(dashboard = UiState.Success(current.data.copy(siteMode = result.data.siteMode))) + } + } + _state.update { it.copy(switching = false, feedback = Feedback(true, R.string.admin_site_mode_updated)) } + load() + } + is ApiResult.HttpError -> + _state.update { + it.copy( + switching = false, + feedback = Feedback( + false, + if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed, + ), + ) + } + is ApiResult.NetworkError -> + _state.update { it.copy(switching = false, feedback = Feedback(false, R.string.error_network)) } + } + } + } +} 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 88d1b6a..9a2ab42 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 @@ -23,6 +23,12 @@ enum class MenuAccess { /** Visible only to a player — the linked game-data groups (§6.3). */ PLAYER, + + /** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */ + STAFF, + + /** Visible to admin/moderator — moderation actions + the support queue (§1, M10). */ + MODERATOR, } data class MenuEntry( @@ -48,6 +54,8 @@ val APP_MENU: List = listOf( MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER), + // 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), ) /** @@ -60,5 +68,7 @@ fun visibleEntries(entries: List, session: Session): List MenuAccess.PUBLIC -> true MenuAccess.SIGNED_IN -> session is Session.SignedIn MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer + MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff + MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator } } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index b508b12..f088cf6 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -38,6 +38,13 @@ object Routes { /** A single character sheet by in-game (hex) serial. */ const val PLAYER_CHAR = "player/char/{serial}" + /** Staff operations (§1, §6.4, M10). Gated to staff roles by the menu access level; + * the backend re-checks role on every `/admin/…` call. */ + const val ADMIN_DASHBOARD = "admin/dashboard" + const val ADMIN_MODERATION = "admin/moderation" + const val ADMIN_SUPPORT = "admin/support" + const val ADMIN_CONTENT = "admin/content" + /** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */ const val PAGE = "page/{slug}" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b753bc4..4e31311 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,10 +46,29 @@ My characters My vendors My houses + Dashboard + Content + Moderation + Support queue Sign in Sign out Change server + + Site + Counts + Recent activity + Live + Maintenance + Switch to maintenance + Switch to live + by %1$s + Site mode updated. + Users + Posts + You don\'t have permission for that action. + That action couldn\'t be completed. Please try again. + Sign in Sign in with your shard account. diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt index 61ab317..40ec162 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt @@ -64,4 +64,24 @@ class MenuAccessTest { assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty()) assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty()) } + + @Test fun staffSeeTheAdminDashboardButPlayersDoNot() { + // STAFF entries (M10) show for every staff role, never for a player or anon. + for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) { + assertTrue("$role should see the dashboard", routes(signedIn(role)).contains(Routes.ADMIN_DASHBOARD)) + } + assertFalse(routes(signedIn(Role.PLAYER)).contains(Routes.ADMIN_DASHBOARD)) + assertFalse(routes(Session.SignedOut).contains(Routes.ADMIN_DASHBOARD)) + } + + @Test fun moderatorAccessIsAdminAndModeratorOnly() { + // A synthetic MODERATOR-gated entry (moderation / support) is visible to + // admin + moderator, but NOT editor, player, or anon. + val entries = listOf(MenuEntry("mod", 0, MenuAccess.MODERATOR)) + assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isNotEmpty()) + assertTrue(visibleEntries(entries, signedIn(Role.MODERATOR)).isNotEmpty()) + assertTrue(visibleEntries(entries, signedIn(Role.EDITOR)).isEmpty()) + assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isEmpty()) + assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty()) + } }