feat(admin): moderation + support queue (M10 Phase 3, part 3)
The final two staff groups, both admin/moderator (MODERATOR menu access; StaffGate now takes a role predicate). Over the shard write plane `/admin/shard/*`: - Moderation: kick / ban / unban an account + broadcast a system message (AdminModerationScreen form + AdminModerationViewModel guarded actions). - Support queue: list open help pages, reply (optionally closing), close (AdminSupportScreen + AdminSupportViewModel). These need a live sidecar; offline they degrade cleanly (a clear error on writes, an empty queue on the list) — never a crash (§7). AdminApi/AdminDto/AdminRepository extended with the shard-op + help-page endpoints. Verified on emulator: both entries appear for an admin (drawer now scrolls through all four staff items); moderation broadcast returns a clean failure with the shard offline; the support queue shows its empty state. assembleDebug + lint green. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,16 @@ package com.runicgateway.app.data.api
|
|||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.BanRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.KickRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||||
@@ -65,4 +71,27 @@ interface AdminApi {
|
|||||||
|
|
||||||
@GET("api/v1/admin/wiki/tags")
|
@GET("api/v1/admin/wiki/tags")
|
||||||
suspend fun wikiTags(): List<AdminWikiTagDto>
|
suspend fun wikiTags(): List<AdminWikiTagDto>
|
||||||
|
|
||||||
|
// ── Moderation: shard write plane (admin/moderator) ───────────────────
|
||||||
|
@POST("api/v1/admin/shard/kick")
|
||||||
|
suspend fun kick(@Body body: KickRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/ban")
|
||||||
|
suspend fun ban(@Body body: BanRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/unban")
|
||||||
|
suspend fun unban(@Body body: UnbanRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/broadcast")
|
||||||
|
suspend fun broadcast(@Body body: BroadcastRequest): Response<Unit>
|
||||||
|
|
||||||
|
// ── Support queue: help pages (admin/moderator) ───────────────────────
|
||||||
|
@GET("api/v1/admin/shard/pages")
|
||||||
|
suspend fun supportPages(): List<SupportPageDto>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/pages/{id}/respond")
|
||||||
|
suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/pages/{id}/close")
|
||||||
|
suspend fun closePage(@Path("id") id: String): Response<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,3 +126,54 @@ data class AdminWikiTagDto(
|
|||||||
val label: String = "",
|
val label: String = "",
|
||||||
@SerialName("published_count") val publishedCount: Int? = null,
|
@SerialName("published_count") val publishedCount: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Moderation (admin/moderator; shard write plane) ───────────────────────
|
||||||
|
|
||||||
|
/** `POST /admin/shard/kick` — at least one of account/serial. */
|
||||||
|
@Serializable
|
||||||
|
data class KickRequest(val account: String? = null, val serial: String? = null)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */
|
||||||
|
@Serializable
|
||||||
|
data class BanRequest(
|
||||||
|
val account: String? = null,
|
||||||
|
val serial: String? = null,
|
||||||
|
@SerialName("durationSec") val durationSec: Long? = null,
|
||||||
|
val reason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/unban`. */
|
||||||
|
@Serializable
|
||||||
|
data class UnbanRequest(val account: String)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/broadcast` — a system message to everyone online. */
|
||||||
|
@Serializable
|
||||||
|
data class BroadcastRequest(val text: String, val hue: Int? = null)
|
||||||
|
|
||||||
|
// ── Support queue (admin/moderator; help pages) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId`
|
||||||
|
* is the sender's in-game serial (the `:id` for respond/close). Permissive — the
|
||||||
|
* shard-state fields beyond these (coords, timing) are ignored.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SupportPageDto(
|
||||||
|
@SerialName("pageId") val pageId: String = "",
|
||||||
|
val type: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val handled: Boolean? = null,
|
||||||
|
val handler: String? = null,
|
||||||
|
val sender: SupportActorDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The page's sender (actor object); [account] present when the character is linked. */
|
||||||
|
@Serializable
|
||||||
|
data class SupportActorDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val account: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */
|
||||||
|
@Serializable
|
||||||
|
data class PageRespondRequest(val message: String, val close: Boolean = false)
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ import com.runicgateway.app.core.result.safeApiCall
|
|||||||
import com.runicgateway.app.data.api.AdminApi
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.BanRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.KickRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||||
@@ -58,6 +64,28 @@ class AdminRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
|
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
|
||||||
|
|
||||||
|
// ── Moderation: shard write plane ─────────────────────────────────────
|
||||||
|
suspend fun kick(account: String?, serial: String?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.kick(KickRequest(account, serial)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun unban(account: String): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.unban(UnbanRequest(account)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun broadcast(text: String, hue: Int?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() }
|
||||||
|
|
||||||
|
// ── Support queue: help pages ─────────────────────────────────────────
|
||||||
|
suspend fun supportPages(): ApiResult<List<SupportPageDto>> = safeApiCall { api.supportPages() }
|
||||||
|
|
||||||
|
suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun closePage(id: String): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.closePage(id).requireOk() }
|
||||||
|
|
||||||
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
|
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
|
||||||
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
|
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
|
||||||
private fun Response<Unit>.requireOk() {
|
private fun Response<Unit>.requireOk() {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ 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.AdminContentScreen
|
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||||
|
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||||
|
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
||||||
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
|
||||||
@@ -83,7 +85,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, Routes.ADMIN_CONTENT,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,6 +378,12 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.ADMIN_CONTENT) {
|
composable(Routes.ADMIN_CONTENT) {
|
||||||
StaffGate(session, navController) { AdminContentScreen() }
|
StaffGate(session, navController) { AdminContentScreen() }
|
||||||
}
|
}
|
||||||
|
composable(Routes.ADMIN_MODERATION) {
|
||||||
|
StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
|
||||||
|
}
|
||||||
|
composable(Routes.ADMIN_SUPPORT) {
|
||||||
|
StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,10 +415,11 @@ private fun PlayerGate(
|
|||||||
private fun StaffGate(
|
private fun StaffGate(
|
||||||
session: Session,
|
session: Session,
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isStaff = (session as? Session.SignedIn)?.user?.isStaff == true
|
val ok = (session as? Session.SignedIn)?.user?.let(require) == true
|
||||||
if (isStaff) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
if (ok) 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. */
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* 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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.ui.components.SectionLabel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and
|
||||||
|
* broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required;
|
||||||
|
* offline, actions return a clean "shard offline" message. Fields are entered here;
|
||||||
|
* the [AdminModerationViewModel] performs the guarded action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdminModerationScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AdminModerationViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var account by rememberSaveable { mutableStateOf("") }
|
||||||
|
var serial by rememberSaveable { mutableStateOf("") }
|
||||||
|
var reason by rememberSaveable { mutableStateOf("") }
|
||||||
|
var duration by rememberSaveable { mutableStateOf("") }
|
||||||
|
var broadcast by rememberSaveable { mutableStateOf("") }
|
||||||
|
val busy = state.busy
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
|
||||||
|
) {
|
||||||
|
state.feedback?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(it.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account actions ──────────────────────────────────────────────
|
||||||
|
SectionLabel(stringResource(R.string.admin_mod_account_action))
|
||||||
|
OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_kick))
|
||||||
|
}
|
||||||
|
Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_ban))
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_unban))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Broadcast ────────────────────────────────────────────────────
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionLabel(stringResource(R.string.admin_mod_broadcast_section))
|
||||||
|
OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_broadcast))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository.AdminRepository
|
||||||
|
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 moderation actions (PLAN.md §1, M10): kick / ban / unban an account
|
||||||
|
* and broadcast a system message, over the shard write plane (`/admin/shard/…`,
|
||||||
|
* admin/moderator). These need a live sidecar — when the shard is offline the call
|
||||||
|
* fails and the screen shows a clean error, never a crash (§7). The form fields live
|
||||||
|
* in the screen; this VM owns only the busy + feedback state and the actions.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminModerationViewModel @Inject constructor(
|
||||||
|
private val admin: AdminRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(val busy: Boolean = false, val feedback: Feedback? = null)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun kick(account: String, serial: String) {
|
||||||
|
if (account.isBlank() && serial.isBlank()) return badTarget()
|
||||||
|
run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ban(account: String, serial: String, durationSec: Long?, reason: String) {
|
||||||
|
if (account.isBlank() && serial.isBlank()) return badTarget()
|
||||||
|
run(R.string.admin_mod_banned) {
|
||||||
|
admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unban(account: String) {
|
||||||
|
if (account.isBlank()) return badTarget()
|
||||||
|
run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcast(text: String, hue: Int?) {
|
||||||
|
if (text.isBlank()) {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun badTarget() {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val feedback = when (val r = block()) {
|
||||||
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
|
is ApiResult.HttpError -> Feedback(
|
||||||
|
false,
|
||||||
|
when (r.status) {
|
||||||
|
403 -> R.string.admin_forbidden
|
||||||
|
503 -> R.string.admin_mod_shard_offline
|
||||||
|
else -> R.string.admin_action_failed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* 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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.SupportPageDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||||
|
* over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages
|
||||||
|
* (or the shard is offline); every read/write degrades cleanly (§7).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdminSupportScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AdminSupportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var replyTo by remember { mutableStateOf<SupportPageDto?>(null) }
|
||||||
|
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
state.feedback?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(it.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val s = state.pages) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success ->
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.admin_support_empty))
|
||||||
|
} else {
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
items(s.data, key = { it.pageId }) { page ->
|
||||||
|
SupportPageCard(
|
||||||
|
page = page,
|
||||||
|
busy = state.busy,
|
||||||
|
onReply = { replyTo = page },
|
||||||
|
onClose = { viewModel.close(page.pageId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyTo?.let { page ->
|
||||||
|
RespondDialog(
|
||||||
|
page = page,
|
||||||
|
onDismiss = { replyTo = null },
|
||||||
|
onSend = { message, close ->
|
||||||
|
viewModel.respond(page.pageId, message, close)
|
||||||
|
replyTo = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SupportPageCard(
|
||||||
|
page: SupportPageDto,
|
||||||
|
busy: Boolean,
|
||||||
|
onReply: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(page.type, who).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
page.message?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||||
|
TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) }
|
||||||
|
TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RespondDialog(
|
||||||
|
page: SupportPageDto,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSend: (message: String, close: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var message by rememberSaveable { mutableStateOf("") }
|
||||||
|
var alsoClose by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
|
title = { Text(stringResource(R.string.admin_support_reply)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth())
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it })
|
||||||
|
Text(stringResource(R.string.admin_support_close_after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* 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.SupportPageDto
|
||||||
|
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 support (help-page) queue (PLAN.md §1, M10): list open pages, reply
|
||||||
|
* (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator).
|
||||||
|
* The list is served from shard state — empty when no tickets (or the shard is
|
||||||
|
* offline); writes need a live sidecar and fail cleanly otherwise (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminSupportViewModel @Inject constructor(
|
||||||
|
private val admin: AdminRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val pages: UiState<List<SupportPageDto>> = UiState.Loading,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(pages = UiState.Loading) }
|
||||||
|
viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun respond(id: String, message: String, close: Boolean) {
|
||||||
|
if (message.isBlank()) {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) }
|
||||||
|
|
||||||
|
private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val feedback = when (val r = block()) {
|
||||||
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
|
is ApiResult.HttpError -> Feedback(
|
||||||
|
false,
|
||||||
|
when (r.status) {
|
||||||
|
403 -> R.string.admin_forbidden
|
||||||
|
404 -> R.string.admin_support_unknown_page
|
||||||
|
503 -> R.string.admin_mod_shard_offline
|
||||||
|
else -> R.string.admin_action_failed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||||
|
}
|
||||||
|
if (feedback.ok) load()
|
||||||
|
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
// 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_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -100,6 +100,38 @@
|
|||||||
<string name="admin_content_title_required">A title is required.</string>
|
<string name="admin_content_title_required">A title is required.</string>
|
||||||
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
|
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
|
||||||
|
|
||||||
|
<!-- Staff moderation (shard write plane) -->
|
||||||
|
<string name="admin_mod_account_action">Account action</string>
|
||||||
|
<string name="admin_mod_account">Account</string>
|
||||||
|
<string name="admin_mod_serial">Serial (0x…)</string>
|
||||||
|
<string name="admin_mod_reason">Reason (ban)</string>
|
||||||
|
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
|
||||||
|
<string name="admin_mod_kick">Kick</string>
|
||||||
|
<string name="admin_mod_ban">Ban</string>
|
||||||
|
<string name="admin_mod_unban">Unban</string>
|
||||||
|
<string name="admin_mod_broadcast_section">Broadcast</string>
|
||||||
|
<string name="admin_mod_broadcast_text">Message to everyone online</string>
|
||||||
|
<string name="admin_mod_broadcast">Broadcast</string>
|
||||||
|
<string name="admin_mod_kicked">Account kicked.</string>
|
||||||
|
<string name="admin_mod_banned">Account banned.</string>
|
||||||
|
<string name="admin_mod_unbanned">Ban cleared.</string>
|
||||||
|
<string name="admin_mod_broadcasted">Message broadcast.</string>
|
||||||
|
<string name="admin_mod_target_required">Enter an account or serial.</string>
|
||||||
|
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
|
||||||
|
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
|
||||||
|
|
||||||
|
<!-- Staff support queue -->
|
||||||
|
<string name="admin_support_empty">No open help pages.</string>
|
||||||
|
<string name="admin_support_reply">Reply</string>
|
||||||
|
<string name="admin_support_close">Close</string>
|
||||||
|
<string name="admin_support_send">Send</string>
|
||||||
|
<string name="admin_support_message">Reply message</string>
|
||||||
|
<string name="admin_support_close_after">Close the page after replying</string>
|
||||||
|
<string name="admin_support_responded">Reply sent.</string>
|
||||||
|
<string name="admin_support_closed">Page closed.</string>
|
||||||
|
<string name="admin_support_message_required">Enter a reply message.</string>
|
||||||
|
<string name="admin_support_unknown_page">That page is no longer in the queue.</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user