From 2e8124d4966cbfbe37a0344bf008876dafcc321b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 01:41:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2010=20Commit=20D3=20?= =?UTF-8?q?=E2=80=94=20Admin=20Users=20+=20Admin=20landing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/data/AdminUsersRepository.kt | 46 +++ .../minstrel/admin/ui/AdminLandingScreen.kt | 236 +++++++++++++++ .../minstrel/admin/ui/AdminUsersScreen.kt | 271 ++++++++++++++++++ .../minstrel/admin/ui/AdminUsersViewModel.kt | 110 +++++++ .../minstrel/api/endpoints/AdminUsersApi.kt | 49 ++++ .../minstrel/models/AdminUserRef.kt | 15 + .../minstrel/models/wire/AdminUserWire.kt | 29 ++ .../minstrel/nav/MinstrelNavGraph.kt | 6 +- 8 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt new file mode 100644 index 00000000..02d609e7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminUsersRepository.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt new file mode 100644 index 00000000..e5f694a3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt @@ -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.Loading) + val uiState: StateFlow = 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt new file mode 100644 index 00000000..dcef667d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt @@ -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(null) } + var deleteCandidate by remember { mutableStateOf(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, + 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt new file mode 100644 index 00000000..b3d9b5f7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersViewModel.kt @@ -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) : AdminUsersUiState + data class Error(val message: String) : AdminUsersUiState +} + +@HiltViewModel +class AdminUsersViewModel @Inject constructor( + private val repository: AdminUsersRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminUsersUiState.Loading) + val uiState: StateFlow = 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() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt new file mode 100644 index 00000000..3a0d4622 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt new file mode 100644 index 00000000..96fa7bd5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminUserRef.kt @@ -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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt new file mode 100644 index 00000000..18fe9227 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminUserWire.kt @@ -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 = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index ce848b78..fedc385a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Admin", "Admin landing — lands in a later phase.") + AdminLandingScreen(navController = navController) } } composable { @@ -126,7 +128,7 @@ private fun NavGraphBuilder.inShellDetail( } composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Admin · Users", "Lands with admin slice.") + AdminUsersScreen(navController = navController) } } }