From 02b04ec1cc3b28e86a475f9e6e415d7c0e41e6df Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:35:15 -0400 Subject: [PATCH 1/5] feat(admin): move the account list onto its own screen The users section rendered every account inline, so on an instance with any real number of them the invite codes, announcements and maintenance sections below it were several screens down, and the search field had scrolled out of sight by the time you reached the results it filtered. The panel now carries a row with the account count that opens a screen holding the search field and nothing else. Search also waits for a pause in typing before asking: filtering is server-side, so "alex" used to be four requests, three of them stale on arrival. --- .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../lupine/sheaf/ui/admin/AdminPanelScreen.kt | 447 +-------------- .../sheaf/ui/admin/AdminPanelViewModel.kt | 179 ------ .../lupine/sheaf/ui/admin/AdminUsersScreen.kt | 528 ++++++++++++++++++ .../sheaf/ui/admin/AdminUsersViewModel.kt | 242 ++++++++ 5 files changed, 791 insertions(+), 612 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersViewModel.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index 0422ab2..663f3fe 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -115,6 +115,7 @@ object Routes { const val ADMIN_PANEL = "settings/admin" const val ADMIN_AUDIT = "settings/admin/audit" const val ADMIN_JOBS = "settings/admin/jobs" + const val ADMIN_USERS = "settings/admin/users" const val ADMIN_USER_DETAIL = "settings/admin/user/{userId}" const val SYSTEM_SAFETY = "settings/safety" const val SHARING = "settings/sharing" @@ -769,6 +770,12 @@ fun SheafApp( onNavigateUp = { navController.navigateUp() }, onNavigateToAudit = { navController.navigate(Routes.ADMIN_AUDIT) }, onNavigateToJobs = { navController.navigate(Routes.ADMIN_JOBS) }, + onNavigateToUsers = { navController.navigate(Routes.ADMIN_USERS) }, + ) + } + composable(Routes.ADMIN_USERS) { + systems.lupine.sheaf.ui.admin.AdminUsersScreen( + onNavigateUp = { navController.navigateUp() }, onNavigateToUserDetail = { id -> navController.navigate("settings/admin/user/$id") }, ) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt index 4a5bec8..46e1b4d 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.* import androidx.compose.runtime.* @@ -27,7 +26,6 @@ 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 systems.lupine.sheaf.data.model.AdminUserUpdate import systems.lupine.sheaf.data.model.AnnouncementCreate import systems.lupine.sheaf.data.model.AnnouncementRead import systems.lupine.sheaf.data.model.AnnouncementUpdate @@ -42,7 +40,7 @@ fun AdminPanelScreen( onNavigateUp: () -> Unit, onNavigateToAudit: () -> Unit = {}, onNavigateToJobs: () -> Unit = {}, - onNavigateToUserDetail: (String) -> Unit = {}, + onNavigateToUsers: () -> Unit = {}, viewModel: AdminPanelViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -54,13 +52,6 @@ fun AdminPanelScreen( } } - state.recoveryMessage?.let { msg -> - LaunchedEffect(msg) { - kotlinx.coroutines.delay(3000) - viewModel.clearRecoveryMessage() - } - } - Scaffold( contentWindowInsets = WindowInsets(0), topBar = { @@ -100,15 +91,6 @@ fun AdminPanelScreen( } } - state.recoveryMessage?.let { msg -> - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), - ) { - Text(msg, modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.onTertiaryContainer) - } - } - val authStatus = state.authStatus if (authStatus != null && !authStatus.verified) { StepUpSection( @@ -190,33 +172,22 @@ fun AdminPanelScreen( } // ── Users ───────────────────────────────────────────────────────── + // The list itself lives on its own screen: it is unbounded, and + // inline it buried everything below it. SectionHeader("Users", modifier = Modifier.padding(horizontal = 16.dp)) - OutlinedTextField( - value = state.search, - onValueChange = { viewModel.setSearch(it) }, - label = { Text("Search users") }, - leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) - Spacer(Modifier.height(8.dp)) - state.users.forEach { user -> - UserListItem( - user = user, - onUpdate = { update -> viewModel.updateUser(user.id, update) }, - onResetPassword = { reason, newPw -> viewModel.resetPassword(user.id, reason, newPw) }, - onChangeEmail = { reason, newEmail -> viewModel.changeEmail(user.id, reason, newEmail) }, - onDisableTotp = { reason -> viewModel.disableTotp(user.id, reason) }, - onVerifyEmail = { reason -> viewModel.verifyEmail(user.id, reason) }, - onCancelDeletion = { reason -> viewModel.cancelDeletion(user.id, reason) }, - onSuspend = { reason, days -> viewModel.suspendUser(user.id, reason, days) }, - onUnsuspend = { reason -> viewModel.unsuspendUser(user.id, reason) }, - onBan = { reason -> viewModel.banUser(user.id, reason) }, - onUnban = { reason -> viewModel.unbanUser(user.id, reason) }, - onViewDetail = { onNavigateToUserDetail(user.id) }, + OutlinedButton( + onClick = onNavigateToUsers, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Icon(Icons.Outlined.Search, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + state.stats?.totalUsers + ?.let { count -> "Search and manage accounts ($count)" } + ?: "Search and manage accounts", ) - HorizontalDivider() } + Spacer(Modifier.height(8.dp)) // ── Invite Codes ────────────────────────────────────────────────── var showCreateInviteDialog by remember { mutableStateOf(false) } @@ -435,396 +406,6 @@ private fun StepUpSection( } } -@Composable -private fun UserListItem( - user: systems.lupine.sheaf.data.model.AdminUserRead, - onUpdate: (AdminUserUpdate) -> Unit, - onResetPassword: (String, String?) -> Unit, - onChangeEmail: (String, String) -> Unit, - onDisableTotp: (String) -> Unit, - onVerifyEmail: (String) -> Unit, - onCancelDeletion: (String) -> Unit, - onSuspend: (String, Int?) -> Unit, - onUnsuspend: (String) -> Unit, - onBan: (String) -> Unit, - onUnban: (String) -> Unit, - onViewDetail: () -> Unit, -) { - var showDialog by remember { mutableStateOf(false) } - val suspended = user.accountStatus.equals("suspended", ignoreCase = true) - val banned = user.accountStatus.equals("banned", ignoreCase = true) - - Surface(onClick = { showDialog = true }, modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text(user.email, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - supportingContent = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text(user.tier, style = MaterialTheme.typography.bodySmall) - Text("·", style = MaterialTheme.typography.bodySmall) - Text( - user.accountStatus, - style = MaterialTheme.typography.bodySmall, - color = if (suspended || banned) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (user.isAdmin) { - Text("·", style = MaterialTheme.typography.bodySmall) - Text("admin", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary) - } - } - }, - trailingContent = { - IconButton(onClick = onViewDetail) { - Icon(Icons.Outlined.Info, contentDescription = "Account detail") - } - }, - ) - } - - if (showDialog) { - UserEditDialog( - user = user, - onDismiss = { showDialog = false }, - onSave = { update -> onUpdate(update); showDialog = false }, - onResetPassword = { reason, newPw -> onResetPassword(reason, newPw); showDialog = false }, - onChangeEmail = { reason, newEmail -> onChangeEmail(reason, newEmail); showDialog = false }, - onDisableTotp = { reason -> onDisableTotp(reason); showDialog = false }, - onVerifyEmail = { reason -> onVerifyEmail(reason); showDialog = false }, - onCancelDeletion = { reason -> onCancelDeletion(reason); showDialog = false }, - onSuspend = { reason, days -> onSuspend(reason, days); showDialog = false }, - onUnsuspend = { reason -> onUnsuspend(reason); showDialog = false }, - onBan = { reason -> onBan(reason); showDialog = false }, - onUnban = { reason -> onUnban(reason); showDialog = false }, - ) - } -} - -@Composable -private fun UserEditDialog( - user: systems.lupine.sheaf.data.model.AdminUserRead, - onDismiss: () -> Unit, - onSave: (AdminUserUpdate) -> Unit, - onResetPassword: (String, String?) -> Unit, - onChangeEmail: (String, String) -> Unit, - onDisableTotp: (String) -> Unit, - onVerifyEmail: (String) -> Unit, - onCancelDeletion: (String) -> Unit, - onSuspend: (String, Int?) -> Unit, - onUnsuspend: (String) -> Unit, - onBan: (String) -> Unit, - onUnban: (String) -> Unit, -) { - var tier by remember { mutableStateOf(user.tier) } - var isAdmin by remember { mutableStateOf(user.isAdmin) } - var memberLimitText by remember { mutableStateOf(user.memberLimit?.toString() ?: "") } - - var showResetPasswordDialog by remember { mutableStateOf(false) } - var showChangeEmailDialog by remember { mutableStateOf(false) } - var confirmDisableTotp by remember { mutableStateOf(false) } - var confirmVerifyEmail by remember { mutableStateOf(false) } - var confirmCancelDeletion by remember { mutableStateOf(false) } - var showSuspend by remember { mutableStateOf(false) } - var showUnsuspend by remember { mutableStateOf(false) } - var showBan by remember { mutableStateOf(false) } - var showUnban by remember { mutableStateOf(false) } - - val suspended = user.accountStatus.equals("suspended", ignoreCase = true) - val banned = user.accountStatus.equals("banned", ignoreCase = true) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(user.email, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Text("Tier", style = MaterialTheme.typography.labelMedium) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - listOf("free", "plus", "self_hosted").forEachIndexed { index, t -> - SegmentedButton( - selected = tier == t, - onClick = { tier = t }, - shape = SegmentedButtonDefaults.itemShape(index, 3), - ) { Text(t.replace('_', ' '), style = MaterialTheme.typography.labelSmall) } - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = isAdmin, onCheckedChange = { isAdmin = it }) - Text("Admin", style = MaterialTheme.typography.bodyMedium) - } - OutlinedTextField( - value = memberLimitText, - onValueChange = { if (it.all { c -> c.isDigit() }) memberLimitText = it }, - label = { Text("Member limit override") }, - placeholder = { Text("Leave empty for default") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - HorizontalDivider() - Text( - "Recovery Tools", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - OutlinedButton( - onClick = { showResetPasswordDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Reset Password") } - OutlinedButton( - onClick = { showChangeEmailDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Change Email") } - if (!user.emailVerified) { - OutlinedButton( - onClick = { confirmVerifyEmail = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Mark Email Verified") } - } - if (user.totpEnabled) { - OutlinedButton( - onClick = { confirmDisableTotp = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { Text("Disable TOTP") } - } - if (user.accountStatus.contains("delet", ignoreCase = true)) { - OutlinedButton( - onClick = { confirmCancelDeletion = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Cancel Account Deletion") } - } - - // Moderation is hidden for admin accounts: the backend rejects - // suspend/ban against admins, so don't offer a button that 403s. - if (!user.isAdmin) { - HorizontalDivider() - Text( - "Moderation", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (suspended) { - user.suspendedReason?.let { - Text( - "Suspended: $it", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - OutlinedButton( - onClick = { showUnsuspend = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Lift Suspension") } - } else if (!banned) { - OutlinedButton( - onClick = { showSuspend = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { Text("Suspend") } - } - if (banned) { - OutlinedButton( - onClick = { showUnban = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Lift Ban") } - } else { - OutlinedButton( - onClick = { showBan = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { Text("Ban Permanently") } - } - } - } - }, - confirmButton = { - TextButton(onClick = { - onSave(AdminUserUpdate( - tier = tier.takeIf { it != user.tier }, - isAdmin = isAdmin.takeIf { it != user.isAdmin }, - memberLimit = memberLimitText.toIntOrNull(), - clearMemberLimit = if (memberLimitText.isBlank() && user.memberLimit != null) true else null, - )) - }) { Text("Save") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) - - if (showResetPasswordDialog) { - ResetPasswordDialog( - onConfirm = { reason, newPw -> onResetPassword(reason, newPw); showResetPasswordDialog = false }, - onDismiss = { showResetPasswordDialog = false }, - ) - } - - if (showChangeEmailDialog) { - ChangeEmailDialog( - onConfirm = { reason, newEmail -> onChangeEmail(reason, newEmail); showChangeEmailDialog = false }, - onDismiss = { showChangeEmailDialog = false }, - ) - } - - if (confirmDisableTotp) { - AdminReasonDialog( - title = "Disable TOTP?", - message = "Removes two-factor authentication from the account. The user must re-enroll to restore it.", - confirmLabel = "Disable", - destructive = true, - onConfirm = { reason, _ -> onDisableTotp(reason); confirmDisableTotp = false }, - onDismiss = { confirmDisableTotp = false }, - ) - } - - if (confirmVerifyEmail) { - AdminReasonDialog( - title = "Verify email?", - message = "Mark ${user.email} as verified without the user clicking a verification link.", - confirmLabel = "Verify", - onConfirm = { reason, _ -> onVerifyEmail(reason); confirmVerifyEmail = false }, - onDismiss = { confirmVerifyEmail = false }, - ) - } - - if (confirmCancelDeletion) { - AdminReasonDialog( - title = "Cancel deletion?", - message = "Restore ${user.email} and cancel the scheduled account deletion.", - confirmLabel = "Cancel deletion", - onConfirm = { reason, _ -> onCancelDeletion(reason); confirmCancelDeletion = false }, - onDismiss = { confirmCancelDeletion = false }, - ) - } - - if (showSuspend) { - AdminReasonDialog( - title = "Suspend account?", - message = "Soft-bans ${user.email} and revokes their sessions. Leave duration blank for an indefinite suspension.", - confirmLabel = "Suspend", - destructive = true, - includeDuration = true, - onConfirm = { reason, days -> onSuspend(reason, days); showSuspend = false }, - onDismiss = { showSuspend = false }, - ) - } - - if (showUnsuspend) { - AdminReasonDialog( - title = "Lift suspension?", - message = "Restores ${user.email} to active.", - confirmLabel = "Lift", - onConfirm = { reason, _ -> onUnsuspend(reason); showUnsuspend = false }, - onDismiss = { showUnsuspend = false }, - ) - } - - if (showBan) { - AdminReasonDialog( - title = "Ban permanently?", - message = "Permanently bans ${user.email} and revokes their sessions. This does not auto-expire.", - confirmLabel = "Ban", - destructive = true, - onConfirm = { reason, _ -> onBan(reason); showBan = false }, - onDismiss = { showBan = false }, - ) - } - - if (showUnban) { - AdminReasonDialog( - title = "Lift ban?", - message = "Restores ${user.email} to active.", - confirmLabel = "Lift", - onConfirm = { reason, _ -> onUnban(reason); showUnban = false }, - onDismiss = { showUnban = false }, - ) - } -} - - -@Composable -private fun ResetPasswordDialog( - onConfirm: (reason: String, newPassword: String?) -> Unit, - onDismiss: () -> Unit, -) { - var reason by remember { mutableStateOf("") } - var newPassword by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Reset Password") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - "Leave blank to generate a random password (the user will need to use \"Forgot Password\" to regain access).", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - OutlinedTextField( - value = reason, - onValueChange = { reason = it.take(500) }, - label = { Text("Reason (recorded in the audit log)") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = newPassword, - onValueChange = { newPassword = it }, - label = { Text("New password (optional)") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(reason.trim(), newPassword.ifBlank { null }) }, - enabled = reason.isNotBlank(), - ) { Text("Reset") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -@Composable -private fun ChangeEmailDialog( - onConfirm: (reason: String, newEmail: String) -> Unit, - onDismiss: () -> Unit, -) { - var reason by remember { mutableStateOf("") } - var newEmail by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Change Email") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - value = reason, - onValueChange = { reason = it.take(500) }, - label = { Text("Reason (recorded in the audit log)") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = newEmail, - onValueChange = { newEmail = it }, - label = { Text("New email address") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(reason.trim(), newEmail) }, - enabled = newEmail.contains('@') && reason.isNotBlank(), - ) { Text("Change") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - @Composable private fun InviteCodeListItem(invite: InviteCodeRead, onDelete: () -> Unit) { val clipboard = LocalClipboard.current diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelViewModel.kt index 246b553..24b6f3f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelViewModel.kt @@ -4,14 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import systems.lupine.sheaf.data.api.SheafApiService import systems.lupine.sheaf.data.model.AdminAuthStatus -import systems.lupine.sheaf.data.model.AdminChangeEmailRequest -import systems.lupine.sheaf.data.model.AdminReasonBody -import systems.lupine.sheaf.data.model.AdminResetPasswordRequest -import systems.lupine.sheaf.data.model.AdminSuspendRequest import systems.lupine.sheaf.data.model.AdminStats import systems.lupine.sheaf.data.model.AdminStepUpVerify -import systems.lupine.sheaf.data.model.AdminUserRead -import systems.lupine.sheaf.data.model.AdminUserUpdate import systems.lupine.sheaf.data.model.AnnouncementCreate import systems.lupine.sheaf.data.model.BulkApproveRequest import systems.lupine.sheaf.data.model.AnnouncementRead @@ -30,18 +24,15 @@ data class AdminPanelUiState( val isLoading: Boolean = false, val authStatus: AdminAuthStatus? = null, val stats: AdminStats? = null, - val users: List = emptyList(), val approvals: List = emptyList(), val invites: List = emptyList(), val announcements: List = emptyList(), val error: String? = null, val isSteppingUp: Boolean = false, val stepUpError: String? = null, - val search: String = "", val maintenanceMessage: String? = null, val isCreatingInvite: Boolean = false, val createInviteError: String? = null, - val recoveryMessage: String? = null, val isSavingAnnouncement: Boolean = false, val announcementSaved: Boolean = false, val announcementError: String? = null, @@ -94,7 +85,6 @@ class AdminPanelViewModel @Inject constructor( private data class AdminData( val stats: AdminStats, - val users: List, val approvals: List, val invites: List, val announcements: List, @@ -105,7 +95,6 @@ class AdminPanelViewModel @Inject constructor( runCatching { AdminData( stats = api.getAdminStats(), - users = api.getAdminUsers(), approvals = api.getApprovals(), invites = api.listInvites(), announcements = api.listAllAnnouncements(), @@ -115,7 +104,6 @@ class AdminPanelViewModel @Inject constructor( _state.update { it.copy( stats = data.stats, - users = data.users, approvals = data.approvals, invites = data.invites, announcements = data.announcements, @@ -151,26 +139,6 @@ class AdminPanelViewModel @Inject constructor( } } - fun setSearch(query: String) { - _state.update { it.copy(search = query) } - viewModelScope.launch { - runCatching { api.getAdminUsers(search = query.ifBlank { null }) } - .onSuccess { users -> _state.update { it.copy(users = users) } } - } - } - - fun updateUser(id: String, update: AdminUserUpdate) { - viewModelScope.launch { - runCatching { api.updateAdminUser(id, update) } - .onSuccess { updated -> - _state.update { s -> - s.copy(users = s.users.map { if (it.id == id) updated else it }) - } - } - .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to update user")) } } - } - } - fun approveUser(id: String) { viewModelScope.launch { runCatching { api.approveUser(id) } @@ -232,156 +200,10 @@ class AdminPanelViewModel @Inject constructor( } } - fun resetPassword(userId: String, reason: String, newPassword: String?) { - viewModelScope.launch { - runCatching { - api.adminResetPassword( - userId, - AdminResetPasswordRequest(reason = reason, newPassword = newPassword?.ifBlank { null }), - ) - } - .onSuccess { _state.update { it.copy(recoveryMessage = "Password reset successfully") } } - .onFailure { e -> - val msg = if (e is HttpException && e.code() == 403) "Insufficient permissions" - else e.toUserMessage("Failed to reset password") - _state.update { it.copy(error = msg) } - } - } - } - - fun changeEmail(userId: String, reason: String, newEmail: String) { - viewModelScope.launch { - runCatching { api.adminChangeEmail(userId, AdminChangeEmailRequest(reason = reason, newEmail = newEmail)) } - .onSuccess { - _state.update { s -> - s.copy( - recoveryMessage = "Email changed to $newEmail", - users = s.users.map { if (it.id == userId) it.copy(email = newEmail) else it }, - ) - } - } - .onFailure { e -> - val msg = if (e is HttpException && e.code() == 409) "Email already in use" - else e.toUserMessage("Failed to change email") - _state.update { it.copy(error = msg) } - } - } - } - - fun disableTotp(userId: String, reason: String) { - viewModelScope.launch { - runCatching { api.adminDisableTotp(userId, AdminReasonBody(reason)) } - .onSuccess { - _state.update { s -> - s.copy( - recoveryMessage = "TOTP disabled", - users = s.users.map { if (it.id == userId) it.copy(totpEnabled = false) else it }, - ) - } - } - .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to disable TOTP")) } } - } - } - - fun verifyEmail(userId: String, reason: String) { - viewModelScope.launch { - runCatching { api.adminVerifyEmail(userId, AdminReasonBody(reason)) } - .onSuccess { - _state.update { s -> - s.copy( - recoveryMessage = "Email marked as verified", - users = s.users.map { if (it.id == userId) it.copy(emailVerified = true) else it }, - ) - } - } - .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to verify email")) } } - } - } - - fun cancelDeletion(userId: String, reason: String) { - viewModelScope.launch { - runCatching { api.adminCancelDeletion(userId, AdminReasonBody(reason)) } - .onSuccess { - _state.update { s -> - s.copy( - recoveryMessage = "Account deletion cancelled", - users = s.users.map { if (it.id == userId) it.copy(accountStatus = "active") else it }, - ) - } - } - .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to cancel deletion")) } } - } - } - // ── Moderation ────────────────────────────────────────────────────────── - fun suspendUser(userId: String, reason: String, durationDays: Int?) { - moderate( - userId = userId, - call = { api.adminSuspendUser(userId, AdminSuspendRequest(reason = reason, durationDays = durationDays)) }, - newStatus = "suspended", - message = "Account suspended", - failure = "Failed to suspend account", - ) - } - - fun unsuspendUser(userId: String, reason: String) { - moderate( - userId = userId, - call = { api.adminUnsuspendUser(userId, AdminReasonBody(reason)) }, - newStatus = "active", - message = "Suspension lifted", - failure = "Failed to lift suspension", - ) - } - - fun banUser(userId: String, reason: String) { - moderate( - userId = userId, - call = { api.adminBanUser(userId, AdminReasonBody(reason)) }, - newStatus = "banned", - message = "Account banned", - failure = "Failed to ban account", - ) - } - - fun unbanUser(userId: String, reason: String) { - moderate( - userId = userId, - call = { api.adminUnbanUser(userId, AdminReasonBody(reason)) }, - newStatus = "active", - message = "Ban lifted", - failure = "Failed to lift ban", - ) - } - // Shared shape for the four moderation actions: run the call, optimistically // reflect the new account_status in the loaded row, surface a result toast. - private fun moderate( - userId: String, - call: suspend () -> Unit, - newStatus: String, - message: String, - failure: String, - ) { - viewModelScope.launch { - runCatching { call() } - .onSuccess { - _state.update { s -> - s.copy( - recoveryMessage = message, - users = s.users.map { if (it.id == userId) it.copy(accountStatus = newStatus) else it }, - ) - } - } - .onFailure { e -> - val msg = if (e is HttpException && e.code() == 403) "Insufficient permissions" - else e.toUserMessage(failure) - _state.update { it.copy(error = msg) } - } - } - } - fun createAnnouncement(create: AnnouncementCreate) { viewModelScope.launch { _state.update { it.copy(isSavingAnnouncement = true, announcementError = null) } @@ -430,7 +252,6 @@ class AdminPanelViewModel @Inject constructor( fun clearMaintenanceMessage() { _state.update { it.copy(maintenanceMessage = null) } } fun clearStepUpError() { _state.update { it.copy(stepUpError = null) } } fun clearCreateInviteError() { _state.update { it.copy(createInviteError = null) } } - fun clearRecoveryMessage() { _state.update { it.copy(recoveryMessage = null) } } fun clearAnnouncementError() { _state.update { it.copy(announcementError = null) } } fun clearAnnouncementSaved() { _state.update { it.copy(announcementSaved = false) } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersScreen.kt new file mode 100644 index 0000000..913f25c --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersScreen.kt @@ -0,0 +1,528 @@ +package systems.lupine.sheaf.ui.admin + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +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 systems.lupine.sheaf.data.model.AdminUserUpdate +import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.SheafTopAppBar + +/** + * Accounts on this instance: search, moderate, hand off to the detail screen. + * + * Its own screen rather than a section of the admin panel because the list is + * unbounded. On an instance with any real number of accounts it pushed invite + * codes, announcements and maintenance so far down the panel that they read as + * absent, and the search field scrolled away from the results it filtered. + * Here the field stays put and the list is the only thing under it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdminUsersScreen( + onNavigateUp: () -> Unit, + onNavigateToUserDetail: (String) -> Unit, + viewModel: AdminUsersViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + state.message?.let { msg -> + LaunchedEffect(msg) { + kotlinx.coroutines.delay(3000) + viewModel.clearMessage() + } + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Users") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + if (state.error != null) { + ErrorBanner(state.error!!, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + } + + state.message?.let { msg -> + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + ) { + Text(msg, modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.onTertiaryContainer) + } + } + + OutlinedTextField( + value = state.search, + onValueChange = { viewModel.setSearch(it) }, + label = { Text("Search users") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + if (state.search.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearch("") }) { + Icon(Icons.Outlined.Close, contentDescription = "Clear search") + } + } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + + when { + // Only a first load blanks the screen. A search reuses the rows + // already on screen until the new ones arrive, so typing doesn't + // flash the list in and out on every pause. + state.isLoading && state.users.isEmpty() -> { + Box(Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + state.users.isEmpty() -> { + Text( + if (state.search.isBlank()) "No accounts" else "No accounts match \"${state.search}\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.users, key = { it.id }) { user -> + UserListItem( + user = user, + onUpdate = { update -> viewModel.updateUser(user.id, update) }, + onResetPassword = { reason, newPw -> viewModel.resetPassword(user.id, reason, newPw) }, + onChangeEmail = { reason, newEmail -> viewModel.changeEmail(user.id, reason, newEmail) }, + onDisableTotp = { reason -> viewModel.disableTotp(user.id, reason) }, + onVerifyEmail = { reason -> viewModel.verifyEmail(user.id, reason) }, + onCancelDeletion = { reason -> viewModel.cancelDeletion(user.id, reason) }, + onSuspend = { reason, days -> viewModel.suspendUser(user.id, reason, days) }, + onUnsuspend = { reason -> viewModel.unsuspendUser(user.id, reason) }, + onBan = { reason -> viewModel.banUser(user.id, reason) }, + onUnban = { reason -> viewModel.unbanUser(user.id, reason) }, + onViewDetail = { onNavigateToUserDetail(user.id) }, + ) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun UserListItem( + user: systems.lupine.sheaf.data.model.AdminUserRead, + onUpdate: (AdminUserUpdate) -> Unit, + onResetPassword: (String, String?) -> Unit, + onChangeEmail: (String, String) -> Unit, + onDisableTotp: (String) -> Unit, + onVerifyEmail: (String) -> Unit, + onCancelDeletion: (String) -> Unit, + onSuspend: (String, Int?) -> Unit, + onUnsuspend: (String) -> Unit, + onBan: (String) -> Unit, + onUnban: (String) -> Unit, + onViewDetail: () -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + val suspended = user.accountStatus.equals("suspended", ignoreCase = true) + val banned = user.accountStatus.equals("banned", ignoreCase = true) + + Surface(onClick = { showDialog = true }, modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text(user.email, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(user.tier, style = MaterialTheme.typography.bodySmall) + Text("·", style = MaterialTheme.typography.bodySmall) + Text( + user.accountStatus, + style = MaterialTheme.typography.bodySmall, + color = if (suspended || banned) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (user.isAdmin) { + Text("·", style = MaterialTheme.typography.bodySmall) + Text("admin", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary) + } + } + }, + trailingContent = { + IconButton(onClick = onViewDetail) { + Icon(Icons.Outlined.Info, contentDescription = "Account detail") + } + }, + ) + } + + if (showDialog) { + UserEditDialog( + user = user, + onDismiss = { showDialog = false }, + onSave = { update -> onUpdate(update); showDialog = false }, + onResetPassword = { reason, newPw -> onResetPassword(reason, newPw); showDialog = false }, + onChangeEmail = { reason, newEmail -> onChangeEmail(reason, newEmail); showDialog = false }, + onDisableTotp = { reason -> onDisableTotp(reason); showDialog = false }, + onVerifyEmail = { reason -> onVerifyEmail(reason); showDialog = false }, + onCancelDeletion = { reason -> onCancelDeletion(reason); showDialog = false }, + onSuspend = { reason, days -> onSuspend(reason, days); showDialog = false }, + onUnsuspend = { reason -> onUnsuspend(reason); showDialog = false }, + onBan = { reason -> onBan(reason); showDialog = false }, + onUnban = { reason -> onUnban(reason); showDialog = false }, + ) + } +} + +@Composable +private fun UserEditDialog( + user: systems.lupine.sheaf.data.model.AdminUserRead, + onDismiss: () -> Unit, + onSave: (AdminUserUpdate) -> Unit, + onResetPassword: (String, String?) -> Unit, + onChangeEmail: (String, String) -> Unit, + onDisableTotp: (String) -> Unit, + onVerifyEmail: (String) -> Unit, + onCancelDeletion: (String) -> Unit, + onSuspend: (String, Int?) -> Unit, + onUnsuspend: (String) -> Unit, + onBan: (String) -> Unit, + onUnban: (String) -> Unit, +) { + var tier by remember { mutableStateOf(user.tier) } + var isAdmin by remember { mutableStateOf(user.isAdmin) } + var memberLimitText by remember { mutableStateOf(user.memberLimit?.toString() ?: "") } + + var showResetPasswordDialog by remember { mutableStateOf(false) } + var showChangeEmailDialog by remember { mutableStateOf(false) } + var confirmDisableTotp by remember { mutableStateOf(false) } + var confirmVerifyEmail by remember { mutableStateOf(false) } + var confirmCancelDeletion by remember { mutableStateOf(false) } + var showSuspend by remember { mutableStateOf(false) } + var showUnsuspend by remember { mutableStateOf(false) } + var showBan by remember { mutableStateOf(false) } + var showUnban by remember { mutableStateOf(false) } + + val suspended = user.accountStatus.equals("suspended", ignoreCase = true) + val banned = user.accountStatus.equals("banned", ignoreCase = true) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(user.email, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Text("Tier", style = MaterialTheme.typography.labelMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + listOf("free", "plus", "self_hosted").forEachIndexed { index, t -> + SegmentedButton( + selected = tier == t, + onClick = { tier = t }, + shape = SegmentedButtonDefaults.itemShape(index, 3), + ) { Text(t.replace('_', ' '), style = MaterialTheme.typography.labelSmall) } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isAdmin, onCheckedChange = { isAdmin = it }) + Text("Admin", style = MaterialTheme.typography.bodyMedium) + } + OutlinedTextField( + value = memberLimitText, + onValueChange = { if (it.all { c -> c.isDigit() }) memberLimitText = it }, + label = { Text("Member limit override") }, + placeholder = { Text("Leave empty for default") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + HorizontalDivider() + Text( + "Recovery Tools", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton( + onClick = { showResetPasswordDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Reset Password") } + OutlinedButton( + onClick = { showChangeEmailDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Change Email") } + if (!user.emailVerified) { + OutlinedButton( + onClick = { confirmVerifyEmail = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Mark Email Verified") } + } + if (user.totpEnabled) { + OutlinedButton( + onClick = { confirmDisableTotp = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Disable TOTP") } + } + if (user.accountStatus.contains("delet", ignoreCase = true)) { + OutlinedButton( + onClick = { confirmCancelDeletion = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Cancel Account Deletion") } + } + + // Moderation is hidden for admin accounts: the backend rejects + // suspend/ban against admins, so don't offer a button that 403s. + if (!user.isAdmin) { + HorizontalDivider() + Text( + "Moderation", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (suspended) { + user.suspendedReason?.let { + Text( + "Suspended: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + OutlinedButton( + onClick = { showUnsuspend = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Lift Suspension") } + } else if (!banned) { + OutlinedButton( + onClick = { showSuspend = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Suspend") } + } + if (banned) { + OutlinedButton( + onClick = { showUnban = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Lift Ban") } + } else { + OutlinedButton( + onClick = { showBan = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Ban Permanently") } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + onSave(AdminUserUpdate( + tier = tier.takeIf { it != user.tier }, + isAdmin = isAdmin.takeIf { it != user.isAdmin }, + memberLimit = memberLimitText.toIntOrNull(), + clearMemberLimit = if (memberLimitText.isBlank() && user.memberLimit != null) true else null, + )) + }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) + + if (showResetPasswordDialog) { + ResetPasswordDialog( + onConfirm = { reason, newPw -> onResetPassword(reason, newPw); showResetPasswordDialog = false }, + onDismiss = { showResetPasswordDialog = false }, + ) + } + + if (showChangeEmailDialog) { + ChangeEmailDialog( + onConfirm = { reason, newEmail -> onChangeEmail(reason, newEmail); showChangeEmailDialog = false }, + onDismiss = { showChangeEmailDialog = false }, + ) + } + + if (confirmDisableTotp) { + AdminReasonDialog( + title = "Disable TOTP?", + message = "Removes two-factor authentication from the account. The user must re-enroll to restore it.", + confirmLabel = "Disable", + destructive = true, + onConfirm = { reason, _ -> onDisableTotp(reason); confirmDisableTotp = false }, + onDismiss = { confirmDisableTotp = false }, + ) + } + + if (confirmVerifyEmail) { + AdminReasonDialog( + title = "Verify email?", + message = "Mark ${user.email} as verified without the user clicking a verification link.", + confirmLabel = "Verify", + onConfirm = { reason, _ -> onVerifyEmail(reason); confirmVerifyEmail = false }, + onDismiss = { confirmVerifyEmail = false }, + ) + } + + if (confirmCancelDeletion) { + AdminReasonDialog( + title = "Cancel deletion?", + message = "Restore ${user.email} and cancel the scheduled account deletion.", + confirmLabel = "Cancel deletion", + onConfirm = { reason, _ -> onCancelDeletion(reason); confirmCancelDeletion = false }, + onDismiss = { confirmCancelDeletion = false }, + ) + } + + if (showSuspend) { + AdminReasonDialog( + title = "Suspend account?", + message = "Soft-bans ${user.email} and revokes their sessions. Leave duration blank for an indefinite suspension.", + confirmLabel = "Suspend", + destructive = true, + includeDuration = true, + onConfirm = { reason, days -> onSuspend(reason, days); showSuspend = false }, + onDismiss = { showSuspend = false }, + ) + } + + if (showUnsuspend) { + AdminReasonDialog( + title = "Lift suspension?", + message = "Restores ${user.email} to active.", + confirmLabel = "Lift", + onConfirm = { reason, _ -> onUnsuspend(reason); showUnsuspend = false }, + onDismiss = { showUnsuspend = false }, + ) + } + + if (showBan) { + AdminReasonDialog( + title = "Ban permanently?", + message = "Permanently bans ${user.email} and revokes their sessions. This does not auto-expire.", + confirmLabel = "Ban", + destructive = true, + onConfirm = { reason, _ -> onBan(reason); showBan = false }, + onDismiss = { showBan = false }, + ) + } + + if (showUnban) { + AdminReasonDialog( + title = "Lift ban?", + message = "Restores ${user.email} to active.", + confirmLabel = "Lift", + onConfirm = { reason, _ -> onUnban(reason); showUnban = false }, + onDismiss = { showUnban = false }, + ) + } +} + + +@Composable +private fun ResetPasswordDialog( + onConfirm: (reason: String, newPassword: String?) -> Unit, + onDismiss: () -> Unit, +) { + var reason by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reset Password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Leave blank to generate a random password (the user will need to use \"Forgot Password\" to regain access).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = reason, + onValueChange = { reason = it.take(500) }, + label = { Text("Reason (recorded in the audit log)") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it }, + label = { Text("New password (optional)") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(reason.trim(), newPassword.ifBlank { null }) }, + enabled = reason.isNotBlank(), + ) { Text("Reset") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun ChangeEmailDialog( + onConfirm: (reason: String, newEmail: String) -> Unit, + onDismiss: () -> Unit, +) { + var reason by remember { mutableStateOf("") } + var newEmail by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Change Email") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = reason, + onValueChange = { reason = it.take(500) }, + label = { Text("Reason (recorded in the audit log)") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = newEmail, + onValueChange = { newEmail = it }, + label = { Text("New email address") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(reason.trim(), newEmail) }, + enabled = newEmail.contains('@') && reason.isNotBlank(), + ) { Text("Change") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersViewModel.kt new file mode 100644 index 0000000..d884068 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminUsersViewModel.kt @@ -0,0 +1,242 @@ +package systems.lupine.sheaf.ui.admin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.HttpException +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.AdminChangeEmailRequest +import systems.lupine.sheaf.data.model.AdminReasonBody +import systems.lupine.sheaf.data.model.AdminResetPasswordRequest +import systems.lupine.sheaf.data.model.AdminSuspendRequest +import systems.lupine.sheaf.data.model.AdminUserRead +import systems.lupine.sheaf.data.model.AdminUserUpdate +import systems.lupine.sheaf.util.toUserMessage +import javax.inject.Inject + +data class AdminUsersUiState( + val isLoading: Boolean = true, + val users: List = emptyList(), + val search: String = "", + val error: String? = null, + val message: String? = null, +) + +/** + * The account list and everything done to a single account from it. + * + * Split out of [AdminPanelViewModel] along with the screen. Step-up is still + * the panel's job: this is only reachable through it, and every call here + * fails closed with the server's 401 if that ever stops being true. + */ +@HiltViewModel +class AdminUsersViewModel @Inject constructor( + private val api: SheafApiService, +) : ViewModel() { + + private val _state = MutableStateFlow(AdminUsersUiState()) + val state: StateFlow = _state.asStateFlow() + + private var searchJob: Job? = null + + init { + viewModelScope.launch { fetch(null) } + } + + fun setSearch(query: String) { + _state.update { it.copy(search = query) } + // Filtering happens server-side, so the old code spent one round trip + // per keystroke: "alex" was four searches, three of them already stale + // by the time they landed. Wait for a pause in typing instead. + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + fetch(query.ifBlank { null }) + } + } + + private suspend fun fetch(search: String?) { + _state.update { it.copy(isLoading = true, error = null) } + runCatching { api.getAdminUsers(search = search) } + .onSuccess { users -> _state.update { it.copy(isLoading = false, users = users) } } + .onFailure { e -> + _state.update { it.copy(isLoading = false, error = e.toUserMessage("Failed to load users")) } + } + } + + fun updateUser(id: String, update: AdminUserUpdate) { + viewModelScope.launch { + runCatching { api.updateAdminUser(id, update) } + .onSuccess { updated -> + _state.update { s -> s.copy(users = s.users.map { if (it.id == id) updated else it }) } + } + .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to update user")) } } + } + } + + // ── Recovery ──────────────────────────────────────────────────────────── + + fun resetPassword(userId: String, reason: String, newPassword: String?) { + viewModelScope.launch { + runCatching { + api.adminResetPassword( + userId, + AdminResetPasswordRequest(reason = reason, newPassword = newPassword?.ifBlank { null }), + ) + } + .onSuccess { _state.update { it.copy(message = "Password reset successfully") } } + .onFailure { e -> + val msg = if (e is HttpException && e.code() == 403) "Insufficient permissions" + else e.toUserMessage("Failed to reset password") + _state.update { it.copy(error = msg) } + } + } + } + + fun changeEmail(userId: String, reason: String, newEmail: String) { + viewModelScope.launch { + runCatching { api.adminChangeEmail(userId, AdminChangeEmailRequest(reason = reason, newEmail = newEmail)) } + .onSuccess { + _state.update { s -> + s.copy( + message = "Email changed to $newEmail", + users = s.users.map { if (it.id == userId) it.copy(email = newEmail) else it }, + ) + } + } + .onFailure { e -> + val msg = if (e is HttpException && e.code() == 409) "Email already in use" + else e.toUserMessage("Failed to change email") + _state.update { it.copy(error = msg) } + } + } + } + + fun disableTotp(userId: String, reason: String) { + viewModelScope.launch { + runCatching { api.adminDisableTotp(userId, AdminReasonBody(reason)) } + .onSuccess { + _state.update { s -> + s.copy( + message = "TOTP disabled", + users = s.users.map { if (it.id == userId) it.copy(totpEnabled = false) else it }, + ) + } + } + .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to disable TOTP")) } } + } + } + + fun verifyEmail(userId: String, reason: String) { + viewModelScope.launch { + runCatching { api.adminVerifyEmail(userId, AdminReasonBody(reason)) } + .onSuccess { + _state.update { s -> + s.copy( + message = "Email marked as verified", + users = s.users.map { if (it.id == userId) it.copy(emailVerified = true) else it }, + ) + } + } + .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to verify email")) } } + } + } + + fun cancelDeletion(userId: String, reason: String) { + viewModelScope.launch { + runCatching { api.adminCancelDeletion(userId, AdminReasonBody(reason)) } + .onSuccess { + _state.update { s -> + s.copy( + message = "Account deletion cancelled", + users = s.users.map { if (it.id == userId) it.copy(accountStatus = "active") else it }, + ) + } + } + .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to cancel deletion")) } } + } + } + + // ── Moderation ────────────────────────────────────────────────────────── + + fun suspendUser(userId: String, reason: String, durationDays: Int?) { + moderate( + userId = userId, + call = { api.adminSuspendUser(userId, AdminSuspendRequest(reason = reason, durationDays = durationDays)) }, + newStatus = "suspended", + message = "Account suspended", + failure = "Failed to suspend account", + ) + } + + fun unsuspendUser(userId: String, reason: String) { + moderate( + userId = userId, + call = { api.adminUnsuspendUser(userId, AdminReasonBody(reason)) }, + newStatus = "active", + message = "Suspension lifted", + failure = "Failed to lift suspension", + ) + } + + fun banUser(userId: String, reason: String) { + moderate( + userId = userId, + call = { api.adminBanUser(userId, AdminReasonBody(reason)) }, + newStatus = "banned", + message = "Account banned", + failure = "Failed to ban account", + ) + } + + fun unbanUser(userId: String, reason: String) { + moderate( + userId = userId, + call = { api.adminUnbanUser(userId, AdminReasonBody(reason)) }, + newStatus = "active", + message = "Ban lifted", + failure = "Failed to lift ban", + ) + } + + // Shared shape for the four moderation actions: run the call, optimistically + // reflect the new account_status in the loaded row, surface a result toast. + private fun moderate( + userId: String, + call: suspend () -> Unit, + newStatus: String, + message: String, + failure: String, + ) { + viewModelScope.launch { + runCatching { call() } + .onSuccess { + _state.update { s -> + s.copy( + message = message, + users = s.users.map { if (it.id == userId) it.copy(accountStatus = newStatus) else it }, + ) + } + } + .onFailure { e -> + val msg = if (e is HttpException && e.code() == 403) "Insufficient permissions" + else e.toUserMessage(failure) + _state.update { it.copy(error = msg) } + } + } + } + + fun clearError() { _state.update { it.copy(error = null) } } + fun clearMessage() { _state.update { it.copy(message = null) } } + + private companion object { + const val SEARCH_DEBOUNCE_MS = 300L + } +} From 215613c2d1afbf284751cf100fe0daaa68138dc4 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:46:11 -0400 Subject: [PATCH 2/5] feat(admin): pick announcement dates, and set logged-out visibility Closes #89. The schedule fields wanted a hand-typed "2026-12-31T00:00:00Z". One typo and the server rejected the whole announcement, and getting it right meant doing the UTC conversion yourself. Both are now a checkbox, a date picker and a time row, seeded through the shared helpers that already keep the front-history picker from landing a day early. The invite code expiry had the same free-text field and gets the same treatment. Visible-while-logged-out has existed server-side since 0.2.0 and was simply absent from the Android models, so an announcement meant for the login page could only be set up from the web client. It is a checkbox in the editor and a marker in the list, matching the web. Unticking a schedule now sends the clear flag: PATCH treats an omitted timestamp as "leave it alone", so without it a removed start or expiry came straight back. --- .../systems/lupine/sheaf/data/model/Models.kt | 3 + .../lupine/sheaf/ui/admin/AdminPanelScreen.kt | 192 +++++++++++++++--- .../sheaf/ui/components/TimeInputRow.kt | 95 +++++++++ .../lupine/sheaf/ui/history/HistoryScreen.kt | 65 ------ .../sheaf/data/model/ModelContractsTest.kt | 33 +++ 5 files changed, 298 insertions(+), 90 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 7a432be..13389e2 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -1313,6 +1313,7 @@ data class AnnouncementRead( val severity: String, val dismissible: Boolean, val active: Boolean, + @Json(name = "visible_while_logged_out") val visibleWhileLoggedOut: Boolean = false, @Json(name = "starts_at") val startsAt: String?, @Json(name = "expires_at") val expiresAt: String?, @Json(name = "created_at") val createdAt: String, @@ -1327,6 +1328,7 @@ data class AnnouncementCreate( val severity: String = "info", val dismissible: Boolean = true, val active: Boolean = true, + @Json(name = "visible_while_logged_out") val visibleWhileLoggedOut: Boolean = false, @Json(name = "starts_at") val startsAt: String? = null, @Json(name = "expires_at") val expiresAt: String? = null, ) @@ -1338,6 +1340,7 @@ data class AnnouncementUpdate( val severity: String? = null, val dismissible: Boolean? = null, val active: Boolean? = null, + @Json(name = "visible_while_logged_out") val visibleWhileLoggedOut: Boolean? = null, @Json(name = "starts_at") val startsAt: String? = null, @Json(name = "expires_at") val expiresAt: String? = null, @Json(name = "clear_starts_at") val clearStartsAt: Boolean = false, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt index 46e1b4d..93e571e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt @@ -31,8 +31,16 @@ import systems.lupine.sheaf.data.model.AnnouncementRead import systems.lupine.sheaf.data.model.AnnouncementUpdate import systems.lupine.sheaf.data.model.InviteCodeRead import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.TimeInputRow +import systems.lupine.sheaf.ui.components.datePickerDate +import systems.lupine.sheaf.ui.components.datePickerMillis import systems.lupine.sheaf.ui.components.SectionHeader import systems.lupine.sheaf.ui.components.SheafTopAppBar +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -291,8 +299,14 @@ fun AdminPanelScreen( severity = create.severity, dismissible = create.dismissible, active = create.active, + visibleWhileLoggedOut = create.visibleWhileLoggedOut, startsAt = create.startsAt, expiresAt = create.expiresAt, + // PATCH reads an omitted timestamp as "leave it + // alone", so unticking a schedule that was set has + // to say so explicitly or it silently stays. + clearStartsAt = create.startsAt == null && editing.startsAt != null, + clearExpiresAt = create.expiresAt == null && editing.expiresAt != null, )) }, onDismiss = { announcementToEdit = null; viewModel.clearAnnouncementError() }, @@ -457,7 +471,7 @@ private fun CreateInviteDialog( ) { var maxUsesText by remember { mutableStateOf("0") } var note by remember { mutableStateOf("") } - var expiresAt by remember { mutableStateOf("") } + var expiresAt by remember { mutableStateOf(null) } AlertDialog( onDismissRequest = { if (!isCreating) onDismiss() }, @@ -479,13 +493,12 @@ private fun CreateInviteDialog( singleLine = true, modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ScheduleField( + label = "Expires", + toggleLabel = "Set an expiry", + offLabel = "Never expires", value = expiresAt, - onValueChange = { expiresAt = it }, - label = { Text("Expires at (optional)") }, - placeholder = { Text("2026-12-31T00:00:00Z") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + onChange = { expiresAt = it }, ) if (error != null) { Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) @@ -494,7 +507,7 @@ private fun CreateInviteDialog( }, confirmButton = { TextButton( - onClick = { onCreate(maxUsesText.toIntOrNull() ?: 0, note, expiresAt) }, + onClick = { onCreate(maxUsesText.toIntOrNull() ?: 0, note, expiresAt?.toString()) }, enabled = !isCreating, ) { if (isCreating) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) @@ -519,10 +532,25 @@ private fun AnnouncementListItem( ListItem( headlineContent = { Text(announcement.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, supportingContent = { + // Same facts the web list badges, in the same order: an + // announcement that greets logged-out visitors should say so + // wherever it is listed. Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Text(announcement.severity, style = MaterialTheme.typography.bodySmall) Text("·", style = MaterialTheme.typography.bodySmall) Text(if (announcement.active) "active" else "inactive", style = MaterialTheme.typography.bodySmall) + if (!announcement.dismissible) { + Text("·", style = MaterialTheme.typography.bodySmall) + Text("non-dismissible", style = MaterialTheme.typography.bodySmall) + } + if (announcement.visibleWhileLoggedOut) { + Text("·", style = MaterialTheme.typography.bodySmall) + Text( + "logged-out", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } } }, trailingContent = { @@ -562,8 +590,9 @@ private fun AnnouncementDialog( var severity by remember { mutableStateOf(initial?.severity ?: "info") } var dismissible by remember { mutableStateOf(initial?.dismissible ?: true) } var active by remember { mutableStateOf(initial?.active ?: true) } - var startsAt by remember { mutableStateOf(initial?.startsAt ?: "") } - var expiresAt by remember { mutableStateOf(initial?.expiresAt ?: "") } + var loggedOut by remember { mutableStateOf(initial?.visibleWhileLoggedOut ?: false) } + var startsAt by remember { mutableStateOf(parseAdminInstant(initial?.startsAt)) } + var expiresAt by remember { mutableStateOf(parseAdminInstant(initial?.expiresAt)) } AlertDialog( onDismissRequest = { if (!isSaving) onDismiss() }, @@ -604,22 +633,40 @@ private fun AnnouncementDialog( Checkbox(checked = active, onCheckedChange = { active = it }) Text("Active", style = MaterialTheme.typography.bodyMedium) } - OutlinedTextField( + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = loggedOut, onCheckedChange = { loggedOut = it }) + Column { + Text("Visible while logged out", style = MaterialTheme.typography.bodyMedium) + Text( + "Shows on the login page", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider() + ScheduleField( + label = "Starts", + toggleLabel = "Schedule a start", + offLabel = "Shows as soon as it's active", value = startsAt, - onValueChange = { startsAt = it }, - label = { Text("Starts at (optional)") }, - placeholder = { Text("2026-01-01T00:00:00Z") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + onChange = { startsAt = it }, ) - OutlinedTextField( + ScheduleField( + label = "Expires", + toggleLabel = "Set an expiry", + offLabel = "Shows until it's turned off", value = expiresAt, - onValueChange = { expiresAt = it }, - label = { Text("Expires at (optional)") }, - placeholder = { Text("2026-12-31T00:00:00Z") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + onChange = { expiresAt = it }, ) + if (startsAt != null && expiresAt != null && !expiresAt!!.isAfter(startsAt)) { + Text( + "The expiry must be after the start.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } if (error != null) { Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } @@ -634,11 +681,13 @@ private fun AnnouncementDialog( severity = severity, dismissible = dismissible, active = active, - startsAt = startsAt.ifBlank { null }, - expiresAt = expiresAt.ifBlank { null }, + visibleWhileLoggedOut = loggedOut, + startsAt = startsAt?.toString(), + expiresAt = expiresAt?.toString(), )) }, - enabled = !isSaving && title.isNotBlank() && body.isNotBlank(), + enabled = !isSaving && title.isNotBlank() && body.isNotBlank() && + (startsAt == null || expiresAt == null || expiresAt!!.isAfter(startsAt)), ) { if (isSaving) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) else Text(if (initial == null) "Create" else "Save") @@ -650,6 +699,99 @@ private fun AnnouncementDialog( ) } +/** + * An optional point in time, as a checkbox plus a date button and a time row. + * + * Replaces a free-text field that wanted a hand-typed `2026-12-31T00:00:00Z`: + * one typo and the server rejected the whole announcement, and getting it right + * meant doing the UTC conversion in your head. The value is carried as an + * [Instant] and shown in the device's zone, so what the picker says is what the + * announcement does locally. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScheduleField( + label: String, + toggleLabel: String, + offLabel: String, + value: Instant?, + onChange: (Instant?) -> Unit, +) { + val zone = remember { ZoneId.systemDefault() } + val local = value?.atZone(zone)?.toLocalDateTime() + var showDatePicker by remember { mutableStateOf(false) } + + if (showDatePicker && local != null) { + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = datePickerMillis(local.toLocalDate()) + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + pickerState.selectedDateMillis?.let { millis -> + onChange( + LocalDateTime.of(datePickerDate(millis), local.toLocalTime()) + .atZone(zone).toInstant(), + ) + } + showDatePicker = false + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } }, + ) { DatePicker(state = pickerState) } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = value != null, + // Defaulting to "now" rather than a blank field: an announcement + // scheduled from here is nearly always minutes or hours out, and + // adjusting a filled-in time is less work than building one. + onCheckedChange = { checked -> + onChange( + if (checked) LocalDateTime.now(zone).withSecond(0).withNano(0) + .atZone(zone).toInstant() + else null, + ) + }, + ) + Text( + if (value == null) offLabel else toggleLabel, + style = MaterialTheme.typography.bodyMedium, + color = if (value == null) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + ) + } + if (local != null) { + OutlinedButton(onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth()) { + Text(local.toLocalDate().format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) + } + TimeInputRow( + time = local.toLocalTime(), + onTimeChange = { time -> + onChange(LocalDateTime.of(local.toLocalDate(), time).atZone(zone).toInstant()) + }, + ) + } + } +} + +/** + * Read a timestamp the server sent back, or null if it is absent or unreadable. + * + * Both columns are `timestamptz`, so an offset is expected; the bare-[Instant] + * fallback covers a `Z` suffix, and anything else drops to null rather than + * throwing inside a composable. + */ +private fun parseAdminInstant(iso: String?): Instant? = + if (iso.isNullOrBlank()) null + else runCatching { OffsetDateTime.parse(iso).toInstant() } + .recoverCatching { Instant.parse(iso) } + .getOrNull() + @Composable private fun MaintenanceButton(label: String, onClick: () -> Unit) { var confirm by remember { mutableStateOf(false) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt new file mode 100644 index 0000000..4f66e30 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt @@ -0,0 +1,95 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +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.unit.dp +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * Hour / minute / AM-PM, as a row of fields. + * + * Shared so that every place in the app asking for a time of day asks for it + * the same way: front history entries, and the schedule on a server + * announcement. Pair it with a `DatePickerDialog` seeded through + * [datePickerMillis] to build a full timestamp. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeInputRow(time: LocalTime, onTimeChange: (LocalTime) -> Unit) { + // These must NOT be keyed on `time`: every accepted keystroke calls + // onTimeChange, which updates the parent `time`, which would re-key the + // remember, replace this text state, and snap the cursor back to the + // start, making the field impossible to type into. The fields are the + // source of truth while editing and `time` only ever changes via them. + var hourText by remember { mutableStateOf(time.format(DateTimeFormatter.ofPattern("h"))) } + var minuteText by remember { mutableStateOf(time.format(DateTimeFormatter.ofPattern("mm"))) } + var isPm by remember { mutableStateOf(time.hour >= 12) } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = hourText, + onValueChange = { v -> + if (v.length <= 2 && v.all { it.isDigit() }) { + hourText = v + val h = v.toIntOrNull() ?: return@OutlinedTextField + if (h in 1..12) { + val hour24 = if (isPm) { if (h == 12) 12 else h + 12 } else { if (h == 12) 0 else h } + onTimeChange(time.withHour(hour24).withMinute(minuteText.toIntOrNull() ?: time.minute)) + } + } + }, + label = { Text("Hour") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Text(":", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = minuteText, + onValueChange = { v -> + if (v.length <= 2 && v.all { it.isDigit() }) { + minuteText = v + val m = v.toIntOrNull() ?: return@OutlinedTextField + if (m in 0..59) onTimeChange(time.withMinute(m)) + } + }, + label = { Text("Min") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.weight(1f)) { + SegmentedButton( + selected = !isPm, + onClick = { + isPm = false + val h = if (time.hour >= 12) time.hour - 12 else time.hour + onTimeChange(time.withHour(if (h == 0) 0 else h)) + }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { Text("AM") } + SegmentedButton( + selected = isPm, + onClick = { + isPm = true + val h = if (time.hour < 12) time.hour + 12 else time.hour + onTimeChange(time.withHour(h)) + }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { Text("PM") } + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt index 37b8d9c..135afee 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt @@ -913,71 +913,6 @@ private fun FrontEntrySheet( } } -@Composable -private fun TimeInputRow(time: LocalTime, onTimeChange: (LocalTime) -> Unit) { - // These must NOT be keyed on `time`: every accepted keystroke calls - // onTimeChange, which updates the parent `time`, which would re-key the - // remember, replace this text state, and snap the cursor back to the - // start — making the field impossible to type into. The fields are the - // source of truth while editing and `time` only ever changes via them. - var hourText by remember { mutableStateOf(time.format(DateTimeFormatter.ofPattern("h"))) } - var minuteText by remember { mutableStateOf(time.format(DateTimeFormatter.ofPattern("mm"))) } - var isPm by remember { mutableStateOf(time.hour >= 12) } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = hourText, - onValueChange = { v -> - if (v.length <= 2 && v.all { it.isDigit() }) { - hourText = v - val h = v.toIntOrNull() ?: return@OutlinedTextField - if (h in 1..12) { - val hour24 = if (isPm) { if (h == 12) 12 else h + 12 } else { if (h == 12) 0 else h } - onTimeChange(time.withHour(hour24).withMinute(minuteText.toIntOrNull() ?: time.minute)) - } - } - }, - label = { Text("Hour") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Text(":", style = MaterialTheme.typography.titleMedium) - OutlinedTextField( - value = minuteText, - onValueChange = { v -> - if (v.length <= 2 && v.all { it.isDigit() }) { - minuteText = v - val m = v.toIntOrNull() ?: return@OutlinedTextField - if (m in 0..59) onTimeChange(time.withMinute(m)) - } - }, - label = { Text("Min") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.weight(1f)) { - SegmentedButton( - selected = !isPm, - onClick = { - isPm = false - val h = if (time.hour >= 12) time.hour - 12 else time.hour - onTimeChange(time.withHour(if (h == 0) 0 else h)) - }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { Text("AM") } - SegmentedButton( - selected = isPm, - onClick = { - isPm = true - val h = if (time.hour < 12) time.hour + 12 else time.hour - onTimeChange(time.withHour(h)) - }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { Text("PM") } - } - } -} - // ── Helpers ─────────────────────────────────────────────────────────────────── private val timePattern = DateTimeFormatter.ofPattern("MMM d, h:mm a") diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt index a2d33dd..de06133 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt @@ -3,6 +3,7 @@ package systems.lupine.sheaf.data.model import com.squareup.moshi.Moshi import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * Wire-contract details that a refactor can quietly break without any compiler @@ -195,4 +196,36 @@ class ModelContractsTest { moshi.adapter(SystemUpdate::class.java).toJson(SystemUpdate(showMemberCreatedDate = false)), ) } + + @Test fun `an announcement decodes its logged-out visibility`() { + val read = moshi.adapter(AnnouncementRead::class.java).fromJson( + """ + {"id":"a1","title":"Maintenance","body":"Back soon","severity":"warning", + "dismissible":true,"active":true,"visible_while_logged_out":true, + "starts_at":"2026-01-01T00:00:00Z","expires_at":null, + "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z", + "created_by":null} + """.trimIndent(), + ) + assertEquals(true, read?.visibleWhileLoggedOut) + } + + @Test fun `creating an announcement sends the flag under its wire name`() { + // snake_case again: without the @Json mapping the server sees an unknown + // key, applies its default of false, and the checkbox appears to do + // nothing at all. + val json = moshi.adapter(AnnouncementCreate::class.java) + .toJson(AnnouncementCreate(title = "T", body = "B", visibleWhileLoggedOut = true)) + assertTrue(""""visible_while_logged_out":true""" in json, json) + } + + @Test fun `clearing a schedule is explicit, not an omission`() { + // PATCH is exclude_unset, so a null starts_at means "leave it alone". + // Unticking a schedule that was set has to send clear_starts_at, or the + // old timestamp survives a save that looked like it removed it. + val json = moshi.adapter(AnnouncementUpdate::class.java) + .toJson(AnnouncementUpdate(title = "T", clearStartsAt = true)) + assertTrue(""""clear_starts_at":true""" in json, json) + assertTrue("starts_at" !in json.replace("clear_starts_at", ""), json) + } } From 0248e529212d577cd5313661454dd9a33de20f9e Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:50:29 -0400 Subject: [PATCH 3/5] feat(settings): give About a screen of its own Closes #63. The About row packed version, commit, build time and flavour onto one ellipsised line, so on a narrow screen the commit was cut off and the only way to read it was to turn the phone sideways. Tapping it now opens a screen where each fact is its own row, wraps instead of truncating, and copies to the clipboard. It carries the full commit hash rather than the short one, since that is what a bug report wants, and a Server section with the instance address and whatever the instance reports about itself: version, tag, commit, build time and mode. A server that says nothing gets an explanation and a retry rather than a blank space, because "too old to answer" and "couldn't be reached" look identical from here. --- sheaf/app/build.gradle.kts | 11 ++ .../systems/lupine/sheaf/data/model/Models.kt | 1 + .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../lupine/sheaf/ui/settings/AboutScreen.kt | 166 ++++++++++++++++++ .../sheaf/ui/settings/AboutViewModel.kt | 41 +++++ .../sheaf/ui/settings/SettingsScreen.kt | 3 +- 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutViewModel.kt diff --git a/sheaf/app/build.gradle.kts b/sheaf/app/build.gradle.kts index ac09865..41e31a6 100644 --- a/sheaf/app/build.gradle.kts +++ b/sheaf/app/build.gradle.kts @@ -42,6 +42,16 @@ val gitCommitShort: String = runCatching { .inputStream.bufferedReader().readText().trim() }.getOrNull()?.takeIf { it.isNotBlank() } ?: "unknown" +// Full SHA of the same commit. The short one identifies a build at a glance; +// this is the one you paste into a bug report or hand to `git show`, which is +// why About offers it as a copyable field rather than only on screen. +val gitCommitFull: String = runCatching { + ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start() + .inputStream.bufferedReader().readText().trim() +}.getOrNull()?.takeIf { it.isNotBlank() } ?: "unknown" + // Build timestamp (UTC) at configure time, also surfaced via BuildConfig. // Same purpose as gitCommitShort but answers "when was this APK compiled" — // useful for the "wait, did I actually install the new build" moment when @@ -79,6 +89,7 @@ android { versionName = providers.gradleProperty("versionName").orNull ?: "$latestReleaseTag-dev" buildConfigField("String", "GIT_COMMIT", "\"$gitCommitShort\"") + buildConfigField("String", "GIT_COMMIT_FULL", "\"$gitCommitFull\"") buildConfigField("String", "BUILD_TIME", "\"$buildTimestamp\"") } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 13389e2..a8af575 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -43,6 +43,7 @@ data class AuthConfig( data class ServerVersion( val version: String? = null, @Json(name = "git_tag") val gitTag: String? = null, + @Json(name = "git_commit") val gitCommit: String? = null, @Json(name = "build_time") val buildTime: String? = null, val mode: String? = null, ) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index 663f3fe..bb3a84a 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -116,6 +116,7 @@ object Routes { const val ADMIN_AUDIT = "settings/admin/audit" const val ADMIN_JOBS = "settings/admin/jobs" const val ADMIN_USERS = "settings/admin/users" + const val ABOUT = "settings/about" const val ADMIN_USER_DETAIL = "settings/admin/user/{userId}" const val SYSTEM_SAFETY = "settings/safety" const val SHARING = "settings/sharing" @@ -532,6 +533,7 @@ fun SheafApp( onNavigateToAdminPanel = { navController.navigate(Routes.ADMIN_PANEL) }, onNavigateToSupport = { navController.navigate(Routes.SUPPORT) }, onNavigateToDebug = { navController.navigate(Routes.DEBUG) }, + onNavigateToAbout = { navController.navigate(Routes.ABOUT) }, ) } composable(Routes.SETTINGS_ACCOUNT) { @@ -773,6 +775,11 @@ fun SheafApp( onNavigateToUsers = { navController.navigate(Routes.ADMIN_USERS) }, ) } + composable(Routes.ABOUT) { + systems.lupine.sheaf.ui.settings.AboutScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } composable(Routes.ADMIN_USERS) { systems.lupine.sheaf.ui.admin.AdminUsersScreen( onNavigateUp = { navController.navigateUp() }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutScreen.kt new file mode 100644 index 0000000..90f4a73 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutScreen.kt @@ -0,0 +1,166 @@ +package systems.lupine.sheaf.ui.settings + +import android.content.ClipData +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.launch +import systems.lupine.sheaf.BuildConfig +import systems.lupine.sheaf.ui.components.SectionHeader +import systems.lupine.sheaf.ui.components.SheafTopAppBar + +/** + * Exactly which build this is, and exactly what it is talking to. + * + * The settings row it hangs off crams the same facts onto one ellipsised line, + * which on a narrow screen truncated the commit before you could read it: the + * only way to see the rest was to rotate the phone. Here every field gets its + * own row, wraps rather than truncates, and copies to the clipboard on tap, + * because the thing most often wanted from this screen is a commit hash pasted + * into a bug report. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + onNavigateUp: () -> Unit, + viewModel: AboutViewModel = hiltViewModel(), +) { + val serverVersion by viewModel.serverVersion.collectAsState() + val baseUrl by viewModel.baseUrl.collectAsState() + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("About") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + SectionHeader("App", modifier = Modifier.padding(horizontal = 16.dp)) + DetailRow("Version", BuildConfig.VERSION_NAME) + DetailRow("Version code", BuildConfig.VERSION_CODE.toString()) + DetailRow("Commit", BuildConfig.GIT_COMMIT_FULL) + DetailRow("Built", BuildConfig.BUILD_TIME) + DetailRow( + "Distribution", + buildString { + append(BuildConfig.FLAVOR) + append(if (BuildConfig.DEBUG) ", debug" else ", release") + }, + ) + DetailRow("Package", BuildConfig.APPLICATION_ID) + + SectionHeader("Server", modifier = Modifier.padding(horizontal = 16.dp)) + DetailRow("Address", baseUrl.ifBlank { "Not configured" }, copyable = baseUrl.isNotBlank()) + val version = serverVersion + if (version == null) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "This server hasn't said what it's running.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + // Worth saying: an instance that predates the endpoint + // reads identically to one that is simply unreachable, + // and the difference matters when something is broken. + "Either it's older than the version endpoint, or it couldn't be reached.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { viewModel.refresh() }) { Text("Try again") } + } + } else { + // Every field is optional: a server built outside CI reports a + // version and little else, and blank rows would just be noise. + version.version?.takeIf { it.isNotBlank() }?.let { DetailRow("Version", it) } + version.gitTag?.takeIf { it.isNotBlank() }?.let { DetailRow("Tag", it) } + version.gitCommit?.takeIf { it.isNotBlank() }?.let { DetailRow("Commit", it) } + version.buildTime?.takeIf { it.isNotBlank() }?.let { DetailRow("Built", it) } + version.mode?.takeIf { it.isNotBlank() }?.let { DetailRow("Mode", it) } + } + } + } +} + +/** + * One fact, its label, and a copy button. + * + * The value is deliberately unconstrained in line count: a full commit hash on + * a narrow screen is the case this screen exists for, and eliding it here would + * reproduce the bug. + */ +@Composable +private fun DetailRow(label: String, value: String, copyable: Boolean = true) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(value, style = MaterialTheme.typography.bodyMedium) + } + if (copyable) { + IconButton(onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value))) + } + }) { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = "Copy $label", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + HorizontalDivider() +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutViewModel.kt new file mode 100644 index 0000000..726ed39 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/AboutViewModel.kt @@ -0,0 +1,41 @@ +package systems.lupine.sheaf.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import systems.lupine.sheaf.data.model.ServerVersion +import systems.lupine.sheaf.data.repository.PreferencesRepository +import systems.lupine.sheaf.data.repository.ServerInfoRepository +import javax.inject.Inject + +/** + * What About needs beyond `BuildConfig`: which instance this is and what it is + * running. + * + * The version is whatever the shared repository already holds, so opening + * About does not re-ask on every visit; [refresh] exists for the case where it + * could not be reached the first time. + */ +@HiltViewModel +class AboutViewModel @Inject constructor( + private val serverInfo: ServerInfoRepository, + prefs: PreferencesRepository, +) : ViewModel() { + + val serverVersion: StateFlow = serverInfo.version + + val baseUrl: StateFlow = prefs.baseUrl + .map { it.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "") + + init { refresh() } + + fun refresh() { + viewModelScope.launch { serverInfo.ensureLoaded() } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt index 11add41..aae6aa4 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt @@ -67,6 +67,7 @@ fun SettingsScreen( onNavigateToAdminPanel: () -> Unit, onNavigateToSupport: () -> Unit, onNavigateToDebug: () -> Unit, + onNavigateToAbout: () -> Unit, settingsViewModel: SettingsViewModel = hiltViewModel(), authViewModel: AuthViewModel = hiltViewModel(), ) { @@ -310,7 +311,7 @@ fun SettingsScreen( append(" · ${BuildConfig.FLAVOR}") if (BuildConfig.DEBUG) append(" · debug") }, - onClick = {}, + onClick = onNavigateToAbout, ) Spacer(Modifier.height(24.dp)) From a95530cfe1667734ef9ed7e678b7b76e2d9372b1 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:02:25 -0400 Subject: [PATCH 4/5] changelog for the settings and admin screens --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4098..cbb66d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses semantic versioning (`MAJOR.MINOR.PATCH`). +## [Unreleased] + +### Added + +- **About has a screen of its own.** The settings row squeezed the version, + commit, build time and flavour onto one line, so on a narrow screen the + commit was cut off and the only way to read it was to turn the phone + sideways. Tapping it now opens a screen where each fact gets its own row and + copies to the clipboard, including the full commit hash rather than the short + one. It also reports what the server says it is: version, tag, commit, build + time and mode. + +- **Announcements can be scheduled with a date picker.** Admin only. The start + and expiry wanted a hand-typed `2026-12-31T00:00:00Z`, where one typo meant + the server rejected the whole announcement. Both are now a date picker and a + time field, as is the expiry on an invite code. + +- **Announcements can be set to show on the login page.** Admin only. The + server has supported this for a long time and the setting simply wasn't on + the phone, so it could only be set from the web client. + +### Changed + +- **The admin account list is its own screen.** It rendered every account + inline, which pushed invite codes, announcements and maintenance several + screens down and left the search field scrolled off above its own results. + Searching also waits for a pause in typing now, instead of asking the server + again on every keystroke. + ## [1.4.1] - 2026-09-20 ### Changed From ca496fab32d67e475ae85e516a97d29252b1eb3b Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:24 -0400 Subject: [PATCH 5/5] fix(fields): stop long field names spelling themselves down the screen The custom fields list put a privacy chip and three icon buttons in the row's trailing slot. ListItem hands that slot the width it asks for and gives the headline whatever is left, which on a narrow screen was about one character: "Primary Source(s)" rendered as a vertical column of letters. Reported from the field. The chip moves down beside the field type, where it has a line to itself, leaving only the three buttons trailing. The name is capped at two lines with an ellipsis so nothing added to that slot later can collapse it again. Same shape as the fix on a member's profile, and as the groups list, which never had the problem because it gives the name a weighted column of its own. --- .../sheaf/ui/fields/CustomFieldsScreen.kt | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt index 786dd17..4751c9d 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.data.model.CustomFieldRead @@ -211,13 +212,39 @@ private fun FieldListItem( onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { + // The name shares this row with three icon buttons, and ListItem gives the + // trailing slot the width it asks for before the headline gets any. With + // the privacy chip in there too the name was left a column one character + // wide, spelling itself vertically down the screen. Reported from the + // field. The chip moves down beside the type, where it has a full line to + // sit on, and the name is capped at two lines so no future addition to the + // trailing slot can collapse it again. ListItem( headlineContent = { - Text(field.name, style = MaterialTheme.typography.titleMedium) + Text( + field.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) }, supportingContent = { - Column { - Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) + SuggestionChip( + onClick = {}, + label = { + Text( + field.privacyDisplay, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } PendingDeleteBadge(field.pendingDeleteAt) } }, @@ -231,17 +258,8 @@ private fun FieldListItem( trailingContent = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), ) { - SuggestionChip( - onClick = {}, - label = { - Text( - field.privacyDisplay, - style = MaterialTheme.typography.labelSmall, - ) - }, - ) // This order is what a member's profile and any shared page // show their fields in, so it is worth being able to set. // See GroupCard: no explicit tint, so the button's own