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 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,15 @@ data class SessionUser(
|
|||||||
val role: Role,
|
val role: Role,
|
||||||
) {
|
) {
|
||||||
val isPlayer: Boolean get() = role == Role.PLAYER
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
31
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
31
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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<AdminActivityDto> = 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<String, Int> = 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,
|
||||||
|
)
|
||||||
@@ -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<AdminDashboardDto> = safeApiCall { api.dashboard() }
|
||||||
|
|
||||||
|
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
|
||||||
|
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
|
|||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
import com.runicgateway.app.data.api.MeApi
|
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.NotificationsApi
|
||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
@@ -119,6 +120,11 @@ object NetworkModule {
|
|||||||
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
||||||
retrofit.create(NotificationsApi::class.java)
|
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,
|
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import com.runicgateway.app.ui.navigation.Routes
|
|||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||||
import com.runicgateway.app.ui.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
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.notifications.NotificationsScreen
|
||||||
import com.runicgateway.app.ui.page.PageScreen
|
import com.runicgateway.app.ui.page.PageScreen
|
||||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
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.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||||
Routes.NOTIFICATIONS,
|
Routes.NOTIFICATIONS,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
|
Routes.ADMIN_DASHBOARD,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -361,6 +363,15 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.PLAYER_HOUSES) {
|
composable(Routes.PLAYER_HOUSES) {
|
||||||
PlayerGate(session, navController) { MyHousesScreen() }
|
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. */
|
/** Navigate to a top-level menu destination: single instance, reset to it. */
|
||||||
private fun NavHostController.navigateTopLevel(route: String) {
|
private fun NavHostController.navigateTopLevel(route: String) {
|
||||||
navigate(route) {
|
navigate(route) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminDashboardDto> = 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> = _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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ enum class MenuAccess {
|
|||||||
|
|
||||||
/** Visible only to a player — the linked game-data groups (§6.3). */
|
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||||
PLAYER,
|
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(
|
data class MenuEntry(
|
||||||
@@ -48,6 +54,8 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
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_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
||||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, 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<MenuEntry>, session: Session): List<MenuEntry>
|
|||||||
MenuAccess.PUBLIC -> true
|
MenuAccess.PUBLIC -> true
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ object Routes {
|
|||||||
/** A single character sheet by in-game (hex) serial. */
|
/** A single character sheet by in-game (hex) serial. */
|
||||||
const val PLAYER_CHAR = "player/char/{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). */
|
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
||||||
const val PAGE = "page/{slug}"
|
const val PAGE = "page/{slug}"
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,29 @@
|
|||||||
<string name="menu_my_characters">My characters</string>
|
<string name="menu_my_characters">My characters</string>
|
||||||
<string name="menu_my_vendors">My vendors</string>
|
<string name="menu_my_vendors">My vendors</string>
|
||||||
<string name="menu_my_houses">My houses</string>
|
<string name="menu_my_houses">My houses</string>
|
||||||
|
<string name="menu_admin_dashboard">Dashboard</string>
|
||||||
|
<string name="menu_admin_content">Content</string>
|
||||||
|
<string name="menu_admin_moderation">Moderation</string>
|
||||||
|
<string name="menu_admin_support">Support queue</string>
|
||||||
<string name="menu_sign_in">Sign in</string>
|
<string name="menu_sign_in">Sign in</string>
|
||||||
<string name="menu_sign_out">Sign out</string>
|
<string name="menu_sign_out">Sign out</string>
|
||||||
<string name="menu_change_server">Change server</string>
|
<string name="menu_change_server">Change server</string>
|
||||||
|
|
||||||
|
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
|
||||||
|
<string name="admin_dashboard_site">Site</string>
|
||||||
|
<string name="admin_dashboard_counts">Counts</string>
|
||||||
|
<string name="admin_dashboard_recent_activity">Recent activity</string>
|
||||||
|
<string name="admin_site_live">Live</string>
|
||||||
|
<string name="admin_site_maintenance">Maintenance</string>
|
||||||
|
<string name="admin_site_switch_maintenance">Switch to maintenance</string>
|
||||||
|
<string name="admin_site_switch_live">Switch to live</string>
|
||||||
|
<string name="admin_site_changed_by">by %1$s</string>
|
||||||
|
<string name="admin_site_mode_updated">Site mode updated.</string>
|
||||||
|
<string name="admin_count_users">Users</string>
|
||||||
|
<string name="admin_count_posts">Posts</string>
|
||||||
|
<string name="admin_forbidden">You don\'t have permission for that action.</string>
|
||||||
|
<string name="admin_action_failed">That action couldn\'t be completed. Please try again.</string>
|
||||||
|
|
||||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||||
<string name="login_title">Sign in</string>
|
<string name="login_title">Sign in</string>
|
||||||
<string name="login_subtitle">Sign in with your shard account.</string>
|
<string name="login_subtitle">Sign in with your shard account.</string>
|
||||||
|
|||||||
@@ -64,4 +64,24 @@ class MenuAccessTest {
|
|||||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
||||||
assertTrue(visibleEntries(entries, Session.SignedOut).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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user