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:
2026-05-25 01:41:43 -04:00
parent 9d17636a39
commit 2e8124d496
8 changed files with 760 additions and 2 deletions
@@ -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)
}
}
}