feat(android): Phase 10 Commit D3 — Admin Users + Admin landing
Lands the final two admin slices, closing Phase 10. Mirrors
`flutter_client/lib/admin/admin_users_screen.dart`,
`admin_landing_screen.dart`, and the `AdminUsersController` +
`adminCountsProvider` pieces of `admin_providers.dart`.
New:
- models/wire/AdminUserWire.kt — AdminUserWire + AdminUsersListWire
(server wraps the list in `{"users": [...]}`).
- models/AdminUserRef.kt — domain.
- api/endpoints/AdminUsersApi.kt — Retrofit list + setAdmin +
setAutoApprove + resetPassword + delete. PUT-auto-approve body
field is `auto_approve` (NOT `auto_approve_requests`) — matches
`internal/api/admin_users.go adminAutoApproveReq` and Flutter's
same-name workaround.
- admin/data/AdminUsersRepository.kt — list + four mutation methods.
- admin/ui/AdminUsersViewModel.kt — VM + UiState. Optimistic patch()
helper for the toggles (last-admin-guard rollback via refresh).
Delete is optimistic-remove with same rollback path. resetPassword
is fire-and-forget — server has no rollback for this anyway.
- admin/ui/AdminUsersScreen.kt — list of rows, each with two toggle
rows (Admin / Auto-approve) and Reset-password + Delete affordances.
Both are dialog-gated (typed-new-password / typed-confirm) so a
tap can't blow up an account by accident.
- admin/ui/AdminLandingScreen.kt — VM + counts + Screen. Fans out
to all three admin repositories in parallel via async/await inside
a coroutineScope; surfaces counts in three ElevatedCard tiles
(Requests / Quarantine / Users) that navigate to the slice screens.
Modified:
- nav/MinstrelNavGraph.kt — Admin + AdminUsers routes now render
their real screens; AdminLanding ties the slice together.
isAdmin gating on the kebab still pending Phase 11 AuthController.
Until then the admin entries are reachable from any account, fine
on a single-user dev install but gates before any release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package com.fabledsword.minstrel.admin.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AdminUsersApi
|
||||
import com.fabledsword.minstrel.api.endpoints.ResetPasswordBody
|
||||
import com.fabledsword.minstrel.api.endpoints.SetAdminBody
|
||||
import com.fabledsword.minstrel.api.endpoints.SetAutoApproveBody
|
||||
import com.fabledsword.minstrel.models.AdminUserRef
|
||||
import com.fabledsword.minstrel.models.wire.AdminUserWire
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the admin users list + the four per-user
|
||||
* mutations. Mirrors Flutter's `AdminUsersController`. Same
|
||||
* no-Room-cache, no-MutationQueue pattern as the other admin slices.
|
||||
*/
|
||||
@Singleton
|
||||
class AdminUsersRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: AdminUsersApi = retrofit.create()
|
||||
|
||||
suspend fun list(): List<AdminUserRef> = api.list().users.map { it.toDomain() }
|
||||
|
||||
suspend fun setAdmin(id: String, isAdmin: Boolean) =
|
||||
api.setAdmin(id, SetAdminBody(isAdmin))
|
||||
|
||||
suspend fun setAutoApprove(id: String, autoApprove: Boolean) =
|
||||
api.setAutoApprove(id, SetAutoApproveBody(autoApprove))
|
||||
|
||||
suspend fun resetPassword(id: String, newPassword: String) =
|
||||
api.resetPassword(id, ResetPasswordBody(newPassword))
|
||||
|
||||
suspend fun delete(id: String) = api.delete(id)
|
||||
}
|
||||
|
||||
private fun AdminUserWire.toDomain(): AdminUserRef = AdminUserRef(
|
||||
id = id,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
isAdmin = isAdmin,
|
||||
autoApproveRequests = autoApproveRequests,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.fabledsword.minstrel.admin.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Inbox
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.TriangleAlert
|
||||
import com.composables.icons.lucide.Users
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
||||
|
||||
sealed interface AdminLandingUiState {
|
||||
data object Loading : AdminLandingUiState
|
||||
data class Success(val counts: AdminCounts) : AdminLandingUiState
|
||||
data class Error(val message: String) : AdminLandingUiState
|
||||
}
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class AdminLandingViewModel @Inject constructor(
|
||||
private val requestsRepo: AdminRequestsRepository,
|
||||
private val quarantineRepo: AdminQuarantineRepository,
|
||||
private val usersRepo: AdminUsersRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||
val uiState: StateFlow<AdminLandingUiState> = internal.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
internal.value = AdminLandingUiState.Loading
|
||||
try {
|
||||
val counts = coroutineScope {
|
||||
val req = async { requestsRepo.list().size }
|
||||
val qua = async { quarantineRepo.list().size }
|
||||
val usr = async { usersRepo.list().size }
|
||||
AdminCounts(req.await(), qua.await(), usr.await())
|
||||
}
|
||||
internal.value = AdminLandingUiState.Success(counts)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
internal.value = AdminLandingUiState.Error(
|
||||
e.message ?: "Admin counts load failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Screen ──────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdminLandingScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AdminLandingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Admin") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
MainAppBarActions(
|
||||
navController = navController,
|
||||
currentRouteName = Admin::class.qualifiedName,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||
when (val s = state) {
|
||||
AdminLandingUiState.Loading -> LoadingCentered()
|
||||
is AdminLandingUiState.Error -> EmptyState(
|
||||
title = "Couldn't load admin counts",
|
||||
body = s.message,
|
||||
)
|
||||
is AdminLandingUiState.Success -> SectionList(
|
||||
counts = s.counts,
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
SectionCard(
|
||||
icon = Lucide.Inbox,
|
||||
title = "Requests",
|
||||
subtitle = "Approve or reject Lidarr requests",
|
||||
count = counts.requests,
|
||||
onClick = { navController.navigate(AdminRequests) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SectionCard(
|
||||
icon = Lucide.TriangleAlert,
|
||||
title = "Quarantine",
|
||||
subtitle = "Resolve scan failures",
|
||||
count = counts.quarantine,
|
||||
onClick = { navController.navigate(AdminQuarantine) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SectionCard(
|
||||
icon = Lucide.Users,
|
||||
title = "Users",
|
||||
subtitle = "Manage accounts",
|
||||
count = counts.users,
|
||||
onClick = { navController.navigate(AdminUsers) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
count: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$count",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingCentered() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.fabledsword.minstrel.admin.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.KeyRound
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminUserRef
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdminUsersScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AdminUsersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var resetPasswordFor by remember { mutableStateOf<AdminUserRef?>(null) }
|
||||
var deleteCandidate by remember { mutableStateOf<AdminUserRef?>(null) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Admin · Users") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
MainAppBarActions(
|
||||
navController = navController,
|
||||
currentRouteName = AdminUsers::class.qualifiedName,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||
when (val s = state) {
|
||||
AdminUsersUiState.Loading -> LoadingCentered()
|
||||
AdminUsersUiState.Empty -> EmptyState(
|
||||
title = "No users",
|
||||
body = "No registered users found.",
|
||||
)
|
||||
is AdminUsersUiState.Error -> EmptyState(
|
||||
title = "Couldn't load users",
|
||||
body = s.message,
|
||||
)
|
||||
is AdminUsersUiState.Success -> UserList(
|
||||
users = s.users,
|
||||
onSetAdmin = viewModel::setAdmin,
|
||||
onSetAutoApprove = viewModel::setAutoApprove,
|
||||
onAskResetPassword = { resetPasswordFor = it },
|
||||
onAskDelete = { deleteCandidate = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetPasswordFor?.let { user ->
|
||||
ResetPasswordDialog(
|
||||
user = user,
|
||||
onDismiss = { resetPasswordFor = null },
|
||||
onConfirm = { newPassword ->
|
||||
viewModel.resetPassword(user.id, newPassword)
|
||||
resetPasswordFor = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
deleteCandidate?.let { user ->
|
||||
DeleteConfirmDialog(
|
||||
user = user,
|
||||
onDismiss = { deleteCandidate = null },
|
||||
onConfirm = {
|
||||
viewModel.delete(user.id)
|
||||
deleteCandidate = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserList(
|
||||
users: List<AdminUserRef>,
|
||||
onSetAdmin: (String, Boolean) -> Unit,
|
||||
onSetAutoApprove: (String, Boolean) -> Unit,
|
||||
onAskResetPassword: (AdminUserRef) -> Unit,
|
||||
onAskDelete: (AdminUserRef) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = users, key = { it.id }) { user ->
|
||||
UserRow(
|
||||
user = user,
|
||||
onSetAdmin = { onSetAdmin(user.id, it) },
|
||||
onSetAutoApprove = { onSetAutoApprove(user.id, it) },
|
||||
onResetPassword = { onAskResetPassword(user) },
|
||||
onDelete = { onAskDelete(user) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserRow(
|
||||
user: AdminUserRef,
|
||||
onSetAdmin: (Boolean) -> Unit,
|
||||
onSetAutoApprove: (Boolean) -> Unit,
|
||||
onResetPassword: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = user.displayName?.takeIf { it.isNotEmpty() } ?: user.username,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "@${user.username}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
ToggleRow(label = "Admin", checked = user.isAdmin, onChange = onSetAdmin)
|
||||
ToggleRow(
|
||||
label = "Auto-approve requests",
|
||||
checked = user.autoApproveRequests,
|
||||
onChange = onSetAutoApprove,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onResetPassword) {
|
||||
Icon(
|
||||
Lucide.KeyRound,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text("Reset password")
|
||||
}
|
||||
TextButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Lucide.Trash2,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodyMedium)
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetPasswordDialog(
|
||||
user: AdminUserRef,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Reset password") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Set a new password for @${user.username}.")
|
||||
OutlinedTextField(
|
||||
value = newPassword,
|
||||
onValueChange = { newPassword = it },
|
||||
label = { Text("New password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(newPassword) },
|
||||
enabled = newPassword.isNotEmpty(),
|
||||
) { Text("Reset") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteConfirmDialog(
|
||||
user: AdminUserRef,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete user?") },
|
||||
text = {
|
||||
Text("Delete @${user.username}? This removes the account and all their data.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingCentered() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.fabledsword.minstrel.admin.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
import com.fabledsword.minstrel.models.AdminUserRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface AdminUsersUiState {
|
||||
data object Loading : AdminUsersUiState
|
||||
data object Empty : AdminUsersUiState
|
||||
data class Success(val users: List<AdminUserRef>) : AdminUsersUiState
|
||||
data class Error(val message: String) : AdminUsersUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AdminUsersViewModel @Inject constructor(
|
||||
private val repository: AdminUsersRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||
val uiState: StateFlow<AdminUsersUiState> = internal.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
internal.value = AdminUsersUiState.Loading
|
||||
try {
|
||||
val users = repository.list()
|
||||
internal.value = if (users.isEmpty()) {
|
||||
AdminUsersUiState.Empty
|
||||
} else {
|
||||
AdminUsersUiState.Success(users)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAdmin(id: String, isAdmin: Boolean) = patch(id, { it.copy(isAdmin = isAdmin) }) {
|
||||
repository.setAdmin(it, isAdmin)
|
||||
}
|
||||
|
||||
fun setAutoApprove(id: String, autoApprove: Boolean) =
|
||||
patch(id, { it.copy(autoApproveRequests = autoApprove) }) {
|
||||
repository.setAutoApprove(it, autoApprove)
|
||||
}
|
||||
|
||||
fun resetPassword(id: String, newPassword: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.resetPassword(id, newPassword) }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
val before = internal.value
|
||||
if (before is AdminUsersUiState.Success) {
|
||||
val filtered = before.users.filterNot { it.id == id }
|
||||
internal.value = if (filtered.isEmpty()) {
|
||||
AdminUsersUiState.Empty
|
||||
} else {
|
||||
AdminUsersUiState.Success(filtered)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.delete(id)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun patch(
|
||||
id: String,
|
||||
mutate: (AdminUserRef) -> AdminUserRef,
|
||||
action: suspend (String) -> Unit,
|
||||
) {
|
||||
val before = internal.value
|
||||
if (before is AdminUsersUiState.Success) {
|
||||
internal.value = AdminUsersUiState.Success(
|
||||
before.users.map { if (it.id == id) mutate(it) else it },
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
action(id)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Server-side guard might have rejected the toggle (e.g.
|
||||
// last-admin protection); refetch reconciles state.
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AdminUsersListWire
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/users`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_users.dart`.
|
||||
*
|
||||
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
|
||||
* `auto_approve_requests` — the request shape differs from the
|
||||
* response field name. Verified against `internal/api/admin_users.go
|
||||
* adminAutoApproveReq` (May 2026).
|
||||
*/
|
||||
interface AdminUsersApi {
|
||||
@GET("api/admin/users")
|
||||
suspend fun list(): AdminUsersListWire
|
||||
|
||||
@PUT("api/admin/users/{id}/admin")
|
||||
suspend fun setAdmin(@Path("id") id: String, @Body body: SetAdminBody)
|
||||
|
||||
@PUT("api/admin/users/{id}/auto-approve")
|
||||
suspend fun setAutoApprove(@Path("id") id: String, @Body body: SetAutoApproveBody)
|
||||
|
||||
@POST("api/admin/users/{id}/reset-password")
|
||||
suspend fun resetPassword(@Path("id") id: String, @Body body: ResetPasswordBody)
|
||||
|
||||
@DELETE("api/admin/users/{id}")
|
||||
suspend fun delete(@Path("id") id: String)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SetAdminBody(
|
||||
@kotlinx.serialization.SerialName("is_admin") val isAdmin: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SetAutoApproveBody(
|
||||
@kotlinx.serialization.SerialName("auto_approve") val autoApprove: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordBody(val password: String)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* One admin-side user row. Mirrors Flutter's `AdminUser`. The
|
||||
* admin-scoped variant exposes `autoApproveRequests` and the
|
||||
* canonical `createdAt` that the user-scoped /me response omits.
|
||||
*/
|
||||
data class AdminUserRef(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val displayName: String? = null,
|
||||
val isAdmin: Boolean = false,
|
||||
val autoApproveRequests: Boolean = false,
|
||||
val createdAt: String = "",
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One admin-side user row. Mirrors `adminUserView` from
|
||||
* `internal/api/admin_users.go`. Distinct from the user-scoped /me
|
||||
* profile shape because admin endpoints expose `auto_approve_requests`
|
||||
* and the canonical `created_at`.
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminUserWire(
|
||||
val id: String = "",
|
||||
val username: String = "",
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||
@SerialName("auto_approve_requests") val autoApproveRequests: Boolean = false,
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Envelope of `GET /api/admin/users`. Server wraps the list in
|
||||
* `{"users": [...]}`; the repository unwraps before mapping.
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminUsersListWire(
|
||||
val users: List<AdminUserWire> = emptyList(),
|
||||
)
|
||||
@@ -10,8 +10,10 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
|
||||
import com.fabledsword.minstrel.home.ui.HomeScreen
|
||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||
@@ -83,7 +85,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
||||
}
|
||||
composable<Admin> {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
ComingSoon("Admin", "Admin landing — lands in a later phase.")
|
||||
AdminLandingScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
composable<Requests> {
|
||||
@@ -126,7 +128,7 @@ private fun NavGraphBuilder.inShellDetail(
|
||||
}
|
||||
composable<AdminUsers> {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
ComingSoon("Admin · Users", "Lands with admin slice.")
|
||||
AdminUsersScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user