diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d0e3e..b5fe50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### 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. - **A share view can show everyone set to Public.** Instead of adding members one at a time, turn on "Show everyone set to Public" and the view follows each member's privacy setting from then on. The list you picked by hand is 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 d91298e..5574a47 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, ) { @@ -1351,6 +1352,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, @@ -1365,6 +1367,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, ) @@ -1376,6 +1379,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/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index 0422ab2..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 @@ -115,6 +115,8 @@ 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 ABOUT = "settings/about" const val ADMIN_USER_DETAIL = "settings/admin/user/{userId}" const val SYSTEM_SAFETY = "settings/safety" const val SHARING = "settings/sharing" @@ -531,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) { @@ -769,6 +772,17 @@ 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.ABOUT) { + systems.lupine.sheaf.ui.settings.AboutScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } + 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..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 @@ -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,14 +26,21 @@ 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 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 @@ -42,7 +48,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 +60,6 @@ fun AdminPanelScreen( } } - state.recoveryMessage?.let { msg -> - LaunchedEffect(msg) { - kotlinx.coroutines.delay(3000) - viewModel.clearRecoveryMessage() - } - } - Scaffold( contentWindowInsets = WindowInsets(0), topBar = { @@ -100,15 +99,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 +180,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) } @@ -320,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() }, @@ -435,396 +420,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 @@ -876,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() }, @@ -898,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) @@ -913,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) @@ -938,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 = { @@ -981,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() }, @@ -1023,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) } @@ -1053,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") @@ -1069,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/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 + } +} 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/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 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/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)) 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) + } }