feat(android) + fix detekt: AdminUsers Invites + Requests/PlaylistDetail refactors
Two slices in one push because the detekt failures from the previous slices block landing the Invites work cleanly: Audit v2 #19 — Admin Users Invites section: * New domain Invite model + InviteWire envelope. * AdminInvitesApi (list / create / revoke). * AdminInvitesRepository. * AdminInvitesViewModel with optimistic add/remove + one-shot Channel for the generated-token dialog. * AdminUsersScreen restructured: single LazyColumn with two sections (Users / Invites). Generate button in Invites header opens a dialog for the optional note; after creation a second dialog surfaces the token with a Copy-to-clipboard button. Detekt fixes on already-pushed code: * PlaylistDetailViewModel.regenerate: 3 returns → single early-bail via combined condition. * PlaylistDetailScreen function (63/60): extracted PlaylistDetailContent helper for the when block. * PlaylistHeader function (65/60): extracted PlaylistHeaderActions. * RequestsScreen RequestRow (79/60): extracted RequestRowBody + RequestRowAction + CancelConfirmDialog helpers. * RequestRef.listenRoute: 4 returns → single when expression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.AdminInvitesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.CreateInviteBody
|
||||||
|
import com.fabledsword.minstrel.models.Invite
|
||||||
|
import com.fabledsword.minstrel.models.wire.InviteWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin facade over `/api/admin/invites`. Same shape as the other
|
||||||
|
* admin repos — direct REST, no caching.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdminInvitesRepository @Inject constructor(retrofit: Retrofit) {
|
||||||
|
private val api: AdminInvitesApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun list(): List<Invite> = api.list().invites.map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun create(note: String?): Invite =
|
||||||
|
api.create(CreateInviteBody(note = note?.takeIf { it.isNotEmpty() })).toDomain()
|
||||||
|
|
||||||
|
suspend fun revoke(token: String) {
|
||||||
|
api.revoke(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InviteWire.toDomain(): Invite = Invite(
|
||||||
|
token = token,
|
||||||
|
invitedBy = invitedBy,
|
||||||
|
note = note,
|
||||||
|
createdAt = createdAt,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
redeemedAt = redeemedAt,
|
||||||
|
redeemedBy = redeemedBy,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
|
import com.fabledsword.minstrel.models.Invite
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class AdminInvitesUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val invites: List<Invite> = emptyList(),
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
|
private val repository: AdminInvitesRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
|
val state: StateFlow<AdminInvitesUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
/** One-shot signal for screens to show the generated token + copy UI. */
|
||||||
|
private val createdChannel = Channel<Invite>(Channel.BUFFERED)
|
||||||
|
val created: Flow<Invite> = createdChannel.receiveAsFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(isLoading = true, message = null) }
|
||||||
|
try {
|
||||||
|
val rows = repository.list()
|
||||||
|
internal.update { it.copy(isLoading = false, invites = rows) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Couldn't load invites: ${e.message ?: "unknown error"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generate(note: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val invite = repository.create(note)
|
||||||
|
createdChannel.trySend(invite)
|
||||||
|
// Optimistically prepend; next refresh reconciles.
|
||||||
|
internal.update { it.copy(invites = listOf(invite) + it.invites) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(message = "Couldn't generate invite: ${e.message ?: "unknown error"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revoke(token: String) {
|
||||||
|
val before = internal.value.invites
|
||||||
|
// Optimistic removal.
|
||||||
|
internal.update { it.copy(invites = before.filterNot { i -> i.token == token }) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.revoke(token) }
|
||||||
|
.onFailure { refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.admin.ui
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -9,24 +8,32 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.Copy
|
||||||
import com.composables.icons.lucide.KeyRound
|
import com.composables.icons.lucide.KeyRound
|
||||||
|
import com.composables.icons.lucide.Plus
|
||||||
import com.composables.icons.lucide.Trash2
|
import com.composables.icons.lucide.Trash2
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import com.fabledsword.minstrel.models.Invite
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -41,7 +48,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
|
||||||
@@ -50,10 +56,19 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
|||||||
fun AdminUsersScreen(
|
fun AdminUsersScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: AdminUsersViewModel = hiltViewModel(),
|
viewModel: AdminUsersViewModel = hiltViewModel(),
|
||||||
|
invitesViewModel: AdminInvitesViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val invitesState by invitesViewModel.state.collectAsStateWithLifecycle()
|
||||||
var resetPasswordFor by remember { mutableStateOf<AdminUserRef?>(null) }
|
var resetPasswordFor by remember { mutableStateOf<AdminUserRef?>(null) }
|
||||||
var deleteCandidate by remember { mutableStateOf<AdminUserRef?>(null) }
|
var deleteCandidate by remember { mutableStateOf<AdminUserRef?>(null) }
|
||||||
|
var showGenerateInvite by remember { mutableStateOf(false) }
|
||||||
|
var generatedInvite by remember { mutableStateOf<Invite?>(null) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
invitesViewModel.created.collect { invite ->
|
||||||
|
generatedInvite = invite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -75,26 +90,41 @@ fun AdminUsersScreen(
|
|||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = {
|
||||||
|
viewModel.refresh().join()
|
||||||
|
invitesViewModel.refresh()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
AdminUsersUiState.Loading -> LoadingCentered()
|
item { SectionHeader("Users") }
|
||||||
AdminUsersUiState.Empty -> EmptyState(
|
usersSection(
|
||||||
title = "No users",
|
state = state,
|
||||||
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,
|
onSetAdmin = viewModel::setAdmin,
|
||||||
onSetAutoApprove = viewModel::setAutoApprove,
|
onSetAutoApprove = viewModel::setAutoApprove,
|
||||||
onAskResetPassword = { resetPasswordFor = it },
|
onAskResetPassword = { resetPasswordFor = it },
|
||||||
onAskDelete = { deleteCandidate = it },
|
onAskDelete = { deleteCandidate = it },
|
||||||
)
|
)
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
label = "Invites",
|
||||||
|
trailing = {
|
||||||
|
TextButton(onClick = { showGenerateInvite = true }) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Plus,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Text("Generate")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
invitesSection(
|
||||||
|
state = invitesState,
|
||||||
|
onRevoke = invitesViewModel::revoke,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +137,208 @@ fun AdminUsersScreen(
|
|||||||
onConfirmReset = viewModel::resetPassword,
|
onConfirmReset = viewModel::resetPassword,
|
||||||
onConfirmDelete = viewModel::delete,
|
onConfirmDelete = viewModel::delete,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showGenerateInvite) {
|
||||||
|
GenerateInviteDialog(
|
||||||
|
onDismiss = { showGenerateInvite = false },
|
||||||
|
onConfirm = { note ->
|
||||||
|
invitesViewModel.generate(note)
|
||||||
|
showGenerateInvite = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
generatedInvite?.let { invite ->
|
||||||
|
GeneratedInviteDialog(
|
||||||
|
invite = invite,
|
||||||
|
onDismiss = { generatedInvite = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(label: String, trailing: (@Composable () -> Unit)? = null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (trailing != null) trailing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun androidx.compose.foundation.lazy.LazyListScope.usersSection(
|
||||||
|
state: AdminUsersUiState,
|
||||||
|
onSetAdmin: (String, Boolean) -> Unit,
|
||||||
|
onSetAutoApprove: (String, Boolean) -> Unit,
|
||||||
|
onAskResetPassword: (AdminUserRef) -> Unit,
|
||||||
|
onAskDelete: (AdminUserRef) -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
AdminUsersUiState.Loading -> item { CenteredHint("Loading users…") }
|
||||||
|
AdminUsersUiState.Empty -> item { CenteredHint("No registered users.") }
|
||||||
|
is AdminUsersUiState.Error -> item { CenteredHint(state.message) }
|
||||||
|
is AdminUsersUiState.Success -> items(items = state.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun androidx.compose.foundation.lazy.LazyListScope.invitesSection(
|
||||||
|
state: AdminInvitesUiState,
|
||||||
|
onRevoke: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.isLoading -> item { CenteredHint("Loading invites…") }
|
||||||
|
state.invites.isEmpty() -> item {
|
||||||
|
CenteredHint("No active invites. Use Generate to create one.")
|
||||||
|
}
|
||||||
|
else -> items(items = state.invites, key = { it.token }) { invite ->
|
||||||
|
InviteRow(invite = invite, onRevoke = { onRevoke(invite.token) })
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredHint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InviteRow(invite: Invite, onRevoke: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = invite.token,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val subtitle = buildString {
|
||||||
|
if (!invite.note.isNullOrEmpty()) {
|
||||||
|
append(invite.note)
|
||||||
|
append(" · ")
|
||||||
|
}
|
||||||
|
append(if (invite.isRedeemed) "redeemed" else "expires ${invite.expiresAt}")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!invite.isRedeemed) {
|
||||||
|
IconButton(onClick = onRevoke) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Trash2,
|
||||||
|
contentDescription = "Revoke invite",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenerateInviteDialog(onDismiss: () -> Unit, onConfirm: (String?) -> Unit) {
|
||||||
|
var note by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Generate invite") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Token expires in 24 hours.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = note,
|
||||||
|
onValueChange = { note = it },
|
||||||
|
label = { Text("Note (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(note.takeIf { it.isNotBlank() }) }) {
|
||||||
|
Text("Generate")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Invite created") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Share this token. It expires in 24 hours.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = invite.token,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(invite.token))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Copy,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Text(" Copy", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Close") }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -140,28 +372,6 @@ private fun PendingDialogs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
@Composable
|
||||||
private fun UserRow(
|
private fun UserRow(
|
||||||
user: AdminUserRef,
|
user: AdminUserRef,
|
||||||
@@ -285,9 +495,3 @@ private fun DeleteConfirmDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoadingCentered() {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminInvitesListWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.InviteWire
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for `/api/admin/invites`. Mirrors
|
||||||
|
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
|
||||||
|
*
|
||||||
|
* Server TTL is hardcoded at 24h; the only configurable field is the
|
||||||
|
* optional `note` on create.
|
||||||
|
*/
|
||||||
|
interface AdminInvitesApi {
|
||||||
|
@GET("api/admin/invites")
|
||||||
|
suspend fun list(): AdminInvitesListWire
|
||||||
|
|
||||||
|
@POST("api/admin/invites")
|
||||||
|
suspend fun create(@Body body: CreateInviteBody): InviteWire
|
||||||
|
|
||||||
|
@DELETE("api/admin/invites/{token}")
|
||||||
|
suspend fun revoke(@Path("token") token: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/admin/invites`. The Flutter client omits the
|
||||||
|
* field when note is empty; Kotlin/serialization just serializes
|
||||||
|
* null which the server treats identically.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CreateInviteBody(val note: String? = null)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain shape for one admin-issued registration invite. Mirrors
|
||||||
|
* `flutter_client/lib/models/invite.dart Invite` and the server's
|
||||||
|
* `inviteResp` from `internal/api/admin_invites.go`.
|
||||||
|
*
|
||||||
|
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
|
||||||
|
* the server doesn't denormalise. TTL is hardcoded server-side at
|
||||||
|
* 24h — admins can only customise the optional `note`.
|
||||||
|
*/
|
||||||
|
data class Invite(
|
||||||
|
val token: String,
|
||||||
|
val invitedBy: String,
|
||||||
|
val note: String?,
|
||||||
|
val createdAt: String,
|
||||||
|
val expiresAt: String,
|
||||||
|
val redeemedAt: String? = null,
|
||||||
|
val redeemedBy: String? = null,
|
||||||
|
) {
|
||||||
|
val isRedeemed: Boolean get() = redeemedAt != null
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape for one invite row.
|
||||||
|
* `invited_by`/`redeemed_by` carry user UUIDs.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class InviteWire(
|
||||||
|
val token: String = "",
|
||||||
|
@SerialName("invited_by") val invitedBy: String = "",
|
||||||
|
val note: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
@SerialName("expires_at") val expiresAt: String = "",
|
||||||
|
@SerialName("redeemed_at") val redeemedAt: String? = null,
|
||||||
|
@SerialName("redeemed_by") val redeemedBy: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envelope for `GET /api/admin/invites` — `{"invites": [...]}`.
|
||||||
|
* The single-invite create endpoint returns a bare [InviteWire], so
|
||||||
|
* the list endpoint is the only place this envelope shows up.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AdminInvitesListWire(
|
||||||
|
val invites: List<InviteWire> = emptyList(),
|
||||||
|
)
|
||||||
+70
-47
@@ -267,30 +267,45 @@ fun PlaylistDetailScreen(
|
|||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
PlaylistDetailContent(
|
||||||
PlaylistDetailUiState.Loading -> LoadingCentered()
|
state = state,
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
viewModel = viewModel,
|
||||||
is PlaylistDetailUiState.Success -> {
|
playerViewModel = playerViewModel,
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
navController = navController,
|
||||||
val playerState by playerViewModel.uiState.collectAsState()
|
)
|
||||||
PlaylistDetailBody(
|
}
|
||||||
detail = s.detail,
|
}
|
||||||
likedTrackIds = likedTrackIds,
|
}
|
||||||
playingTrackId = playerState.currentTrack?.id,
|
|
||||||
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
|
@Composable
|
||||||
onShuffleAll = {
|
private fun PlaylistDetailContent(
|
||||||
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
|
state: PlaylistDetailUiState,
|
||||||
},
|
viewModel: PlaylistDetailViewModel,
|
||||||
onRegenerate = viewModel::regenerate,
|
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||||
onTrackClick = { id ->
|
navController: NavHostController,
|
||||||
viewModel.play(s.detail.tracks, startTrackId = id)
|
) {
|
||||||
},
|
when (state) {
|
||||||
onToggleTrackLike = viewModel::toggleLikeTrack,
|
PlaylistDetailUiState.Loading -> LoadingCentered()
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh)
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
is PlaylistDetailUiState.Success -> {
|
||||||
)
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
}
|
val playerState by playerViewModel.uiState.collectAsState()
|
||||||
}
|
PlaylistDetailBody(
|
||||||
|
detail = state.detail,
|
||||||
|
likedTrackIds = likedTrackIds,
|
||||||
|
playingTrackId = playerState.currentTrack?.id,
|
||||||
|
onPlayAll = { viewModel.play(state.detail.tracks, startTrackId = null) },
|
||||||
|
onShuffleAll = {
|
||||||
|
viewModel.play(state.detail.tracks.shuffled(), startTrackId = null)
|
||||||
|
},
|
||||||
|
onRegenerate = viewModel::regenerate,
|
||||||
|
onTrackClick = { id ->
|
||||||
|
viewModel.play(state.detail.tracks, startTrackId = id)
|
||||||
|
},
|
||||||
|
onToggleTrackLike = viewModel::toggleLikeTrack,
|
||||||
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,30 +404,38 @@ private fun PlaylistHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
PlaylistHeaderActions(
|
||||||
Button(
|
refreshable = playlist.refreshable,
|
||||||
onClick = onPlayAll,
|
onPlayAll = onPlayAll,
|
||||||
colors = ButtonDefaults.buttonColors(),
|
onShuffleAll = onShuffleAll,
|
||||||
) {
|
onRegenerate = onRegenerate,
|
||||||
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaylistHeaderActions(
|
||||||
|
refreshable: Boolean,
|
||||||
|
onPlayAll: () -> Unit,
|
||||||
|
onShuffleAll: () -> Unit,
|
||||||
|
onRegenerate: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(onClick = onPlayAll, colors = ButtonDefaults.buttonColors()) {
|
||||||
|
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Play")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onShuffleAll) {
|
||||||
|
Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Shuffle")
|
||||||
|
}
|
||||||
|
if (refreshable) {
|
||||||
|
OutlinedButton(onClick = onRegenerate) {
|
||||||
|
Icon(Lucide.RefreshCw, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Play")
|
Text("Regenerate")
|
||||||
}
|
|
||||||
OutlinedButton(onClick = onShuffleAll) {
|
|
||||||
Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Shuffle")
|
|
||||||
}
|
|
||||||
if (playlist.refreshable) {
|
|
||||||
OutlinedButton(onClick = onRegenerate) {
|
|
||||||
Icon(
|
|
||||||
Lucide.RefreshCw,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Regenerate")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,74 +136,103 @@ private fun RequestRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
KindAvatar(kind = req.kind)
|
KindAvatar(kind = req.kind)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
RequestRowBody(req = req, modifier = Modifier.weight(1f))
|
||||||
Text(
|
RequestRowAction(
|
||||||
text = req.displayName,
|
req = req,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
onListen = onListen,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
onAskCancel = { showCancelConfirm = true },
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
StatusPill(status = req.status)
|
|
||||||
KindPill(label = req.kind)
|
|
||||||
}
|
|
||||||
val progress = req.ingestProgressText()
|
|
||||||
if (progress != null) {
|
|
||||||
Text(
|
|
||||||
text = progress,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (req.status == RequestStatus.REJECTED && !req.notes.isNullOrEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = req.notes,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
onListen != null -> OutlinedButton(onClick = onListen) {
|
|
||||||
Icon(
|
|
||||||
Lucide.Play,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp).padding(end = 4.dp),
|
|
||||||
)
|
|
||||||
Text("Listen")
|
|
||||||
}
|
|
||||||
req.status == RequestStatus.PENDING -> TextButton(onClick = { showCancelConfirm = true }) {
|
|
||||||
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showCancelConfirm) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showCancelConfirm = false },
|
|
||||||
title = { Text("Cancel request?") },
|
|
||||||
text = { Text("Cancel “${req.displayName}”? Lidarr will stop searching for it.") },
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
showCancelConfirm = false
|
|
||||||
onCancel()
|
|
||||||
},
|
|
||||||
) { Text("Cancel request") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showCancelConfirm = false }) { Text("Keep") }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showCancelConfirm) {
|
||||||
|
CancelConfirmDialog(
|
||||||
|
displayName = req.displayName,
|
||||||
|
onConfirm = {
|
||||||
|
showCancelConfirm = false
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onDismiss = { showCancelConfirm = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequestRowBody(req: RequestRef, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = req.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
StatusPill(status = req.status)
|
||||||
|
KindPill(label = req.kind)
|
||||||
|
}
|
||||||
|
val progress = req.ingestProgressText()
|
||||||
|
if (progress != null) {
|
||||||
|
Text(
|
||||||
|
text = progress,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (req.status == RequestStatus.REJECTED && !req.notes.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = req.notes,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequestRowAction(
|
||||||
|
req: RequestRef,
|
||||||
|
onListen: (() -> Unit)?,
|
||||||
|
onAskCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
onListen != null -> OutlinedButton(onClick = onListen) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Play,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp).padding(end = 4.dp),
|
||||||
|
)
|
||||||
|
Text("Listen")
|
||||||
|
}
|
||||||
|
req.status == RequestStatus.PENDING -> TextButton(onClick = onAskCancel) {
|
||||||
|
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CancelConfirmDialog(
|
||||||
|
displayName: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Cancel request?") },
|
||||||
|
text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = onConfirm) { Text("Cancel request") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Keep") }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -230,11 +259,11 @@ private fun KindAvatar(kind: String) {
|
|||||||
* completed. Track-level matches resolve to AlbumDetail since the
|
* completed. Track-level matches resolve to AlbumDetail since the
|
||||||
* client has no TrackDetail screen.
|
* client has no TrackDetail screen.
|
||||||
*/
|
*/
|
||||||
private fun RequestRef.listenRoute(): Any? {
|
private fun RequestRef.listenRoute(): Any? = when {
|
||||||
if (status != RequestStatus.COMPLETED) return null
|
status != RequestStatus.COMPLETED -> null
|
||||||
matchedAlbumId?.takeIf { it.isNotEmpty() }?.let { return AlbumDetail(it) }
|
!matchedAlbumId.isNullOrEmpty() -> AlbumDetail(matchedAlbumId)
|
||||||
matchedArtistId?.takeIf { it.isNotEmpty() }?.let { return ArtistDetail(it) }
|
!matchedArtistId.isNullOrEmpty() -> ArtistDetail(matchedArtistId)
|
||||||
return null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user