feat(android): AdminRequests — show requester username

Audit v2 #18. AdminRequestsScreen was showing only the request's
display name, not which user asked for it. Now fetches the admin
users list in parallel with the requests list, builds a
userId → username map, and renders "Requested by <username>"
beneath each row. Falls back to an 8-char userId prefix when the
users list fetch fails (e.g., admin without users-list access on
some future server-side ACL change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:10:50 -04:00
parent a7f1160b7e
commit cd1c054f62
2 changed files with 37 additions and 5 deletions
@@ -81,6 +81,7 @@ fun AdminRequestsScreen(
) )
is AdminRequestsUiState.Success -> RequestList( is AdminRequestsUiState.Success -> RequestList(
rows = s.rows, rows = s.rows,
usernames = s.usernames,
onApprove = viewModel::approve, onApprove = viewModel::approve,
onReject = viewModel::reject, onReject = viewModel::reject,
) )
@@ -92,12 +93,20 @@ fun AdminRequestsScreen(
@Composable @Composable
private fun RequestList( private fun RequestList(
rows: List<RequestRef>, rows: List<RequestRef>,
usernames: Map<String, String>,
onApprove: (String) -> Unit, onApprove: (String) -> Unit,
onReject: (String) -> Unit, onReject: (String) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req -> items(items = rows, key = { it.id }) { req ->
AdminRequestRow(req = req, onApprove = { onApprove(req.id) }, onReject = { onReject(req.id) }) AdminRequestRow(
req = req,
requester = usernames[req.userId]
?: req.userId.takeIf { it.isNotEmpty() }?.take(8)
?: "unknown",
onApprove = { onApprove(req.id) },
onReject = { onReject(req.id) },
)
HorizontalDivider() HorizontalDivider()
} }
} }
@@ -106,6 +115,7 @@ private fun RequestList(
@Composable @Composable
private fun AdminRequestRow( private fun AdminRequestRow(
req: RequestRef, req: RequestRef,
requester: String,
onApprove: () -> Unit, onApprove: () -> Unit,
onReject: () -> Unit, onReject: () -> Unit,
) { ) {
@@ -122,6 +132,13 @@ private fun AdminRequestRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text(
text = "Requested by $requester",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip( AssistChip(
onClick = {}, onClick = {},
@@ -3,10 +3,13 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -19,13 +22,17 @@ private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface AdminRequestsUiState { sealed interface AdminRequestsUiState {
data object Loading : AdminRequestsUiState data object Loading : AdminRequestsUiState
data object Empty : AdminRequestsUiState data object Empty : AdminRequestsUiState
data class Success(val rows: List<RequestRef>) : AdminRequestsUiState data class Success(
val rows: List<RequestRef>,
val usernames: Map<String, String>,
) : AdminRequestsUiState
data class Error(val message: String) : AdminRequestsUiState data class Error(val message: String) : AdminRequestsUiState
} }
@HiltViewModel @HiltViewModel
class AdminRequestsViewModel @Inject constructor( class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository, private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
) : ViewModel() { ) : ViewModel() {
@@ -44,11 +51,19 @@ class AdminRequestsViewModel @Inject constructor(
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading internal.value = AdminRequestsUiState.Loading
try { try {
val rows = repository.list() val (rows, usernames) = coroutineScope {
val requestsJob = async { repository.list() }
val usernamesJob = async {
runCatching {
usersRepository.list().associate { it.id to it.username }
}.getOrDefault(emptyMap())
}
requestsJob.await() to usernamesJob.await()
}
internal.value = if (rows.isEmpty()) { internal.value = if (rows.isEmpty()) {
AdminRequestsUiState.Empty AdminRequestsUiState.Empty
} else { } else {
AdminRequestsUiState.Success(rows) AdminRequestsUiState.Success(rows = rows, usernames = usernames)
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
@@ -75,7 +90,7 @@ class AdminRequestsViewModel @Inject constructor(
internal.value = if (filtered.isEmpty()) { internal.value = if (filtered.isEmpty()) {
AdminRequestsUiState.Empty AdminRequestsUiState.Empty
} else { } else {
AdminRequestsUiState.Success(filtered) AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames)
} }
} }
viewModelScope.launch { viewModelScope.launch {