From 6be83fe70aff885648cecf2f6828f2903a8ef104 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 00:13:34 -0500 Subject: [PATCH] feat(account): delete my account in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACCOUNT_DELETION.md §4.3 (step 4). My account gains a Delete my account row, last and set apart, which opens its own screen: what goes, what stays, the staff note, then the typed username, the password and, when two-factor is on, an authenticator or recovery code. Delete stays off until the name matches. MeApi.deleteAccount sends DELETE /auth/me/account and reads the raw response, so each refusal's code becomes its own sentence (the website's words). A wrong password is a 400 (AD12), so the authenticator never replays it. On success AuthRepository.forgetDeletedAccount does what a sign-out does without its server calls: the inbox snapshot, the trust token (which a sign-out keeps, but the account is gone) and the session, whose teardown also drops the push registration. It is not cancellable. The route holds the view model, so it tells a deletion from any other sign-out, and opens sign-in with "Your account has been deleted." on the shell's snackbar. Tests: the refusal mapping per code, 429, unknown codes, no answer, the request body; the confirm rule, the staff rule, each refusal's sentence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../com/runicgateway/app/data/api/MeApi.kt | 9 + .../app/data/api/dto/AccountDto.kt | 16 ++ .../app/data/repository/AccountRepository.kt | 69 ++++++ .../app/data/repository/AuthRepository.kt | 24 ++ .../java/com/runicgateway/app/ui/RunicApp.kt | 26 +++ .../app/ui/auth/AccountDeletion.kt | 39 ++++ .../runicgateway/app/ui/auth/AccountScreen.kt | 34 +++ .../app/ui/auth/DeleteAccountScreen.kt | 218 ++++++++++++++++++ .../app/ui/auth/DeleteAccountViewModel.kt | 87 +++++++ .../runicgateway/app/ui/navigation/Routes.kt | 3 + app/src/main/res/values/strings.xml | 32 +++ .../AccountDeletionRepositoryTest.kt | 122 ++++++++++ .../repository/AccountTrustedDevicesTest.kt | 4 + .../app/ui/auth/AccountDeletionTest.kt | 48 ++++ 14 files changed, 731 insertions(+) create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt index 1114bae..feea7ee 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt @@ -5,6 +5,8 @@ package com.runicgateway.app.data.api import com.runicgateway.app.data.api.dto.ChangePasswordRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest +import com.runicgateway.app.data.api.dto.DeleteAccountRequest +import com.runicgateway.app.data.api.dto.DeleteAccountResultDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.PlayerAccountDto import com.runicgateway.app.data.api.dto.RecoveryCodesDto @@ -89,4 +91,11 @@ interface MeApi { @POST("api/v1/auth/me/account/recovery-codes/generate") suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto + + // ── Deleting the account (ACCOUNT_DELETION.md §4.1) ────────────────────── + + // A DELETE with a body needs @HTTP. Raw [Response] so the caller can read the + // refusal's `code` off a 400 or 409, which a thrown HttpException would not carry. + @HTTP(method = "DELETE", path = "api/v1/auth/me/account", hasBody = true) + suspend fun deleteAccount(@Body body: DeleteAccountRequest): Response } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt index 507200f..c1431f1 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt @@ -116,6 +116,22 @@ data class RevokedFlagDto(val revoked: Boolean = false) @Serializable data class RevokedCountDto(val revoked: Int = 0) +/** + * `DELETE /auth/me/account` body (ACCOUNT_DELETION.md §4.1). The password is always + * sent (AD7); a two-factor [code] or a [recoveryCode] only when two-factor is on. + */ +@Serializable +data class DeleteAccountRequest( + val username: String, + val password: String, + val code: String? = null, + val recoveryCode: String? = null, +) + +/** The `{ deleted: true }` a successful deletion answers. */ +@Serializable +data class DeleteAccountResultDto(val deleted: Boolean = false) + /** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */ @Serializable data class RecoveryStatusDto(val remaining: Int = 0) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt index c6702cd..437a460 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt @@ -4,10 +4,13 @@ package com.runicgateway.app.data.repository import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.DEMO_LOCKED_CODE +import com.runicgateway.app.core.result.errorCodeOf import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.data.api.MeApi import com.runicgateway.app.data.api.dto.ChangePasswordRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest +import com.runicgateway.app.data.api.dto.DeleteAccountRequest import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.PlayerAccountDto import com.runicgateway.app.data.api.dto.RecoveryCodesDto @@ -117,4 +120,70 @@ class AccountRepository @Inject constructor( /** Regenerate the single-use codes (password step-up). Returned once — never stored. */ suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult = safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) } + + // ── Deleting the account (ACCOUNT_DELETION.md §4.1, §4.3) ────────────── + + /** Why the server refused a deletion, one per refusal `code` it sends. */ + enum class DeleteRefusal { + USERNAME_MISMATCH, + WRONG_PASSWORD, + CODE_REQUIRED, + WRONG_CODE, + + /** The account has no password yet; it sets one on Account first (AD7). */ + PASSWORD_REQUIRED, + LAST_ADMIN, + + /** The public demo's refusal; the shell's snackbar has already said why. */ + DEMO_LOCKED, + RATE_LIMITED, + + /** Any other answer — a 5xx, or a refusal this app does not know. */ + OTHER, + } + + sealed interface DeleteOutcome { + /** Gone on the server, with every session; the caller forgets it locally. */ + data object Deleted : DeleteOutcome + data class Refused(val reason: DeleteRefusal) : DeleteOutcome + data object NetworkError : DeleteOutcome + } + + /** + * Delete the signed-in account. A second factor is a [code] or a [recoveryCode], + * sent only when two-factor is on. Reads the raw response so a refusal's `code` + * survives; a wrong password is a 400, never a 401 (AD12), so the authenticator + * never replays this call. + */ + suspend fun deleteAccount( + username: String, + password: String, + code: String? = null, + recoveryCode: String? = null, + ): DeleteOutcome { + val response = try { + api.deleteAccount(DeleteAccountRequest(username, password, code, recoveryCode)) + } catch (e: CancellationException) { + throw e + } catch (_: IOException) { + return DeleteOutcome.NetworkError + } catch (_: Exception) { + return DeleteOutcome.Refused(DeleteRefusal.OTHER) + } + if (response.isSuccessful) return DeleteOutcome.Deleted + if (response.code() == 429) return DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED) + val refusal = runCatching { errorCodeOf(response.errorBody()?.string()) }.getOrNull() + return DeleteOutcome.Refused(deleteRefusalOf(refusal)) + } + + private fun deleteRefusalOf(code: String?): DeleteRefusal = when (code) { + "username_mismatch" -> DeleteRefusal.USERNAME_MISMATCH + "wrong_password" -> DeleteRefusal.WRONG_PASSWORD + "code_required" -> DeleteRefusal.CODE_REQUIRED + "wrong_code" -> DeleteRefusal.WRONG_CODE + "password_required" -> DeleteRefusal.PASSWORD_REQUIRED + "last_admin" -> DeleteRefusal.LAST_ADMIN + DEMO_LOCKED_CODE -> DeleteRefusal.DEMO_LOCKED + else -> DeleteRefusal.OTHER + } } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt index 5b70045..d2e966a 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt @@ -17,7 +17,9 @@ import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.api.dto.TotpRequiredError import com.runicgateway.app.data.api.dto.TrustedDeviceDto import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import retrofit2.Response import java.io.IOException @@ -208,6 +210,28 @@ class AuthRepository @Inject constructor( sessionManager.onSignedOut() } + /** + * Forget an account the server has just deleted (ACCOUNT_DELETION.md §4.3): what + * [logout] does, without its two calls, because the server dropped the sessions, + * trusted devices and push registrations with the row and would answer 401. + * + * The trust token goes too, unlike a sign-out: it belongs to an account that no + * longer exists. The push registration goes with the session, through + * [PushManager]'s sign-out observer, as on a dead refresh. + * + * Not cancellable: the account is already gone, so a caller leaving the screen + * must not leave its session behind on the phone. + */ + suspend fun forgetDeletedAccount() = withContext(NonCancellable) { + try { + inboxCache.clear() + } catch (_: Exception) { + // Ignore — the snapshot is scoped to (base URL, user id) and is never shown again. + } + trustTokenStore.clear() + sessionManager.onSignedOut() + } + /** * Re-validate the session against `GET /auth/me` on app resume (§4.3). A * success refreshes the cached role (roles change server-side); a `401` that diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index c483850..3ea35a5 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -62,6 +62,8 @@ import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.ui.auth.AccountScreen +import com.runicgateway.app.ui.auth.DeleteAccountScreen +import com.runicgateway.app.ui.auth.DeleteAccountViewModel import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen import com.runicgateway.app.ui.auth.TrustedDevicesScreen @@ -204,6 +206,7 @@ fun RunicApp( // the one showing rather than queueing behind it. val snackbarHostState = remember { SnackbarHostState() } val demoLockedFallback = stringResource(R.string.error_demo_locked) + val accountDeletedNotice = stringResource(R.string.account_delete_done) LaunchedEffect(Unit) { demoNoticeViewModel.refusals.collectLatest { refusal -> snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback) @@ -417,6 +420,15 @@ fun RunicApp( session = session, onSignOut = { sessionViewModel.signOut() }, onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) }, + onAccountDeleted = { + // To sign-in with the notice, as the web lands home with its own. + // Home stays beneath it, so back leaves sign-in for Home. + navController.navigate(Routes.LOGIN) { + popUpTo(Routes.HOME) + launchSingleTop = true + } + scope.launch { snackbarHostState.showSnackbar(accountDeletedNotice) } + }, modifier = Modifier.padding(innerPadding), ) } @@ -527,6 +539,7 @@ private fun RunicNavHost( session: Session, onSignOut: () -> Unit, onSignOutEverywhere: () -> Unit, + onAccountDeleted: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( @@ -709,6 +722,7 @@ private fun RunicNavHost( onSignOutEverywhere = onSignOutEverywhere, onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) }, onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) }, + onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) }, ) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) @@ -728,6 +742,18 @@ private fun RunicNavHost( Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } + composable(Routes.ACCOUNT_DELETE) { + // The view model is held here, not in the screen, so this route can tell a + // deletion from any other sign-out: the view model marks it deleted before + // the session drops, and both land in the same frame (ACCOUNT_DELETION.md §4.3). + val viewModel: DeleteAccountViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + when { + state.deleted -> LaunchedEffect(Unit) { onAccountDeleted() } + session is Session.SignedIn -> DeleteAccountScreen(viewModel) + else -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } + } + } composable(Routes.NOTIFICATIONS) { // Signed-in only; a sign-out (or demotion) sends the user home rather than // leaving another account's items up. The backend gates every call diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt new file mode 100644 index 0000000..168c1ef --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import androidx.annotation.StringRes +import com.runicgateway.app.R +import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal + +/* + * What the Delete my account screen decides (ACCOUNT_DELETION.md §4.3), kept out + * of the composable so it can be tested. The website's `lib/accountDeletion.js` + * holds the same rules and words. + * + * The server is the guard: it checks the username, the password and the code again + * whatever this file decides. What is here only keeps a mis-tap from reaching it + * and turns its refusals into sentences. + */ + +/** Delete is live once the typed name matches the account's, ignoring case. */ +fun deleteUsernameMatches(typed: String, username: String): Boolean = + typed.isNotBlank() && typed.trim().equals(username, ignoreCase = true) + +/** Every role but player is staff, whose username is never free again (AD9). */ +fun isStaffRole(role: String): Boolean = role.isNotBlank() && !role.equals("player", ignoreCase = true) + +/** The sentence for a refusal. */ +@StringRes +fun deleteRefusalRes(reason: DeleteRefusal): Int = when (reason) { + DeleteRefusal.USERNAME_MISMATCH -> R.string.account_delete_error_username + DeleteRefusal.WRONG_PASSWORD -> R.string.account_delete_error_password + DeleteRefusal.CODE_REQUIRED -> R.string.account_delete_error_code_required + DeleteRefusal.WRONG_CODE -> R.string.account_delete_error_code + DeleteRefusal.PASSWORD_REQUIRED -> R.string.account_delete_no_password + DeleteRefusal.LAST_ADMIN -> R.string.account_delete_error_last_admin + DeleteRefusal.DEMO_LOCKED -> R.string.error_demo_locked + DeleteRefusal.RATE_LIMITED -> R.string.account_delete_error_rate_limited + DeleteRefusal.OTHER -> R.string.account_delete_error +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index be8df3d..ca681c8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -6,6 +6,7 @@ package com.runicgateway.app.ui.auth import android.graphics.BitmapFactory import android.util.Base64 import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -67,6 +69,7 @@ fun AccountScreen( onSignOutEverywhere: () -> Unit, onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit, + onOpenDeleteAccount: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountViewModel = hiltViewModel(), ) { @@ -102,9 +105,40 @@ fun AccountScreen( ) { Text(stringResource(R.string.account_sign_out_all)) } + + DeleteAccountRow(onOpenDeleteAccount) } } +/** + * Last on the screen and set apart (ACCOUNT_DELETION.md §4.3). Always shown, on the + * demo too, where the server refuses: Play's reviewer must be able to find it. + */ +@Composable +private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) { + HorizontalDivider( + Modifier.padding(top = 20.dp, bottom = 16.dp), + color = MaterialTheme.colorScheme.error, + ) + Text( + stringResource(R.string.account_delete_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + stringResource(R.string.account_delete_row_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + OutlinedButton( + onClick = onOpenDeleteAccount, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { Text(stringResource(R.string.account_delete_open)) } +} + @Composable private fun IdentityCard(username: String, roleLabel: String) { ShardCard(Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt new file mode 100644 index 0000000..00f50fc --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt @@ -0,0 +1,218 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.PlayerAccountDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView + +/** What goes with the account. The same list as the website's and BACKEND_DESIGN.md's. */ +private val WHAT_GOES = listOf( + R.string.account_delete_goes_sign_in, + R.string.account_delete_goes_devices, + R.string.account_delete_goes_identities, + R.string.account_delete_goes_game_links, + R.string.account_delete_goes_notifications, +) + +/** What stays, and who sees what of it. */ +private val WHAT_STAYS = listOf( + R.string.account_delete_stays_content, + R.string.account_delete_stays_reports, + R.string.account_delete_stays_events, + R.string.account_delete_stays_game_account, +) + +/** + * Account → Delete my account (ACCOUNT_DELETION.md §4.3): what goes and what stays, + * then the confirm step — the typed username, the password, and a code when + * two-factor is on. Delete stays off until the username matches. On the demo the + * server refuses and the shell's snackbar says why; the screen stays reachable, + * which is what Play's reviewer needs to find. + */ +@Composable +fun DeleteAccountScreen( + viewModel: DeleteAccountViewModel, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + stringResource(R.string.account_delete_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.error, + ) + Paragraph(R.string.account_delete_intro, top = 12) + WHAT_GOES.forEach { Bullet(it) } + Paragraph(R.string.account_delete_stays_title, top = 16) + WHAT_STAYS.forEach { Bullet(it) } + + when (val account = state.account) { + is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp)) + is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp)) + is UiState.Success -> ConfirmStep(account.data, state, viewModel) + } + } +} + +@Composable +private fun ConfirmStep( + account: PlayerAccountDto, + state: DeleteAccountViewModel.State, + viewModel: DeleteAccountViewModel, +) { + var typed by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var code by rememberSaveable { mutableStateOf("") } + var useRecovery by rememberSaveable { mutableStateOf(false) } + + val needsCode = account.totp_enabled + val ready = account.has_password && + deleteUsernameMatches(typed, account.username) && + password.isNotEmpty() && + (!needsCode || code.isNotBlank()) + val enabled = !state.busy && !state.deleted + + if (isStaffRole(account.role)) Paragraph(R.string.account_delete_staff_note, top = 16) + + if (!account.has_password) { + Text( + stringResource(R.string.account_delete_no_password), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 16.dp), + ) + return + } + + OutlinedTextField( + value = typed, + onValueChange = { typed = it }, + singleLine = true, + enabled = enabled, + label = { Text(stringResource(R.string.account_delete_type_username, account.username)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Next, + ), + modifier = Modifier.fillMaxWidth().padding(top = 20.dp), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + singleLine = true, + enabled = enabled, + label = { Text(stringResource(R.string.login_password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + if (needsCode) { + OutlinedTextField( + value = code, + onValueChange = { code = if (useRecovery) it else it.filter(Char::isDigit).take(8) }, + singleLine = true, + enabled = enabled, + label = { + Text(stringResource(if (useRecovery) R.string.account_delete_recovery_code else R.string.login_totp_code)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = if (useRecovery) KeyboardType.Ascii else KeyboardType.NumberPassword, + ), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + TextButton( + onClick = { useRecovery = !useRecovery; code = "" }, + enabled = enabled, + ) { + Text( + stringResource( + if (useRecovery) R.string.account_delete_use_authenticator else R.string.account_delete_use_recovery, + ), + ) + } + } + + state.error?.let { err -> + Text( + text = stringResource(err), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 12.dp), + ) + } + + Button( + onClick = { viewModel.delete(typed, password, code, useRecovery) }, + enabled = enabled && ready, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) { + Text(stringResource(if (state.busy) R.string.account_delete_busy else R.string.account_delete_action)) + } +} + +@Composable +private fun Paragraph(@StringRes res: Int, top: Int) { + Text( + stringResource(res), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = top.dp), + ) +} + +@Composable +private fun Bullet(@StringRes res: Int) { + Row(Modifier.padding(top = 6.dp, start = 4.dp)) { + Text("•", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + stringResource(res), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt new file mode 100644 index 0000000..6a41af6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt @@ -0,0 +1,87 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.PlayerAccountDto +import com.runicgateway.app.data.repository.AccountRepository +import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome +import com.runicgateway.app.data.repository.AuthRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +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 javax.inject.Inject + +/** + * Drives Account → Delete my account (ACCOUNT_DELETION.md §4.3). It loads the + * account to know whether a code is asked for and whether there is a password at + * all, sends `DELETE /auth/me/account`, and on success forgets the account on the + * phone. There is no grace period (AD4): the confirm step is the safeguard. + */ +@HiltViewModel +class DeleteAccountViewModel @Inject constructor( + private val accountRepository: AccountRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + + data class State( + val account: UiState = UiState.Loading, + val busy: Boolean = false, + @param:StringRes val error: Int? = null, + /** + * Set before the session is dropped, so the route sees it in the same frame as + * the sign-out and goes to sign-in with the notice rather than home. + */ + val deleted: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.update { it.copy(account = UiState.Loading) } + viewModelScope.launch { + val account = accountRepository.getAccount() + _state.update { it.copy(account = account.toUiState()) } + } + } + + /** Delete the account. [useRecovery] sends [code] as a recovery code instead. */ + fun delete(typed: String, password: String, code: String, useRecovery: Boolean) { + val account = (_state.value.account as? UiState.Success)?.data ?: return + if (_state.value.busy || _state.value.deleted) return + _state.update { it.copy(busy = true, error = null) } + val secondFactor = code.trim().takeIf { account.totp_enabled && it.isNotEmpty() } + viewModelScope.launch { + val outcome = accountRepository.deleteAccount( + username = typed.trim(), + password = password, + code = secondFactor.takeUnless { useRecovery }, + recoveryCode = secondFactor.takeIf { useRecovery }, + ) + when (outcome) { + DeleteOutcome.Deleted -> { + _state.update { it.copy(busy = false, deleted = true) } + authRepository.forgetDeletedAccount() + } + is DeleteOutcome.Refused -> + _state.update { it.copy(busy = false, error = deleteRefusalRes(outcome.reason)) } + DeleteOutcome.NetworkError -> + _state.update { it.copy(busy = false, error = R.string.error_network) } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 68b3f9c..4de6862 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -38,6 +38,9 @@ object Routes { const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices" const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes" + /** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */ + const val ACCOUNT_DELETE = "account/delete" + /** * The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eeb28b1..8784c26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,6 +197,38 @@ Trusted devices Recovery codes + + Delete my account + Deletes your account straight away. It cannot be restored. + Delete my account… + Your account is deleted straight away and cannot be restored. These go with it: + your sign-in, password, email address, two-factor settings and recovery codes + every signed-in device, trusted device and push registration + your linked sign-ins, such as Google, Discord or Steam + your game-account links, and anything this site granted in the game through them + your notification settings and inbox + What stays: + What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”. + Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses. + Event results stay with the score and the in-game name, with no site account behind them. + A game account stays on the game server. Only its link to this site goes. + You are staff, so your username can never be used again, by anyone, once this account is deleted. + This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion. + Type %1$s to confirm + Recovery code + Use a recovery code instead + Use an authenticator code instead + Delete my account + Deleting… + Your account has been deleted. + Type your username exactly as it is shown. + Your password is incorrect. + Enter a code from your authenticator app, or a recovery code. + That code is not valid. Try again. + You are the only admin. Make someone else an admin before you delete your account. + Too many attempts. Wait a few minutes and try again. + Could not delete your account. Try again. + Trusted devices These devices can skip the authentication code at sign-in for 30 days. diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt new file mode 100644 index 0000000..66fab49 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.data.api.MeApi +import com.runicgateway.app.data.api.dto.ChangePasswordRequest +import com.runicgateway.app.data.api.dto.ChangeUsernameRequest +import com.runicgateway.app.data.api.dto.DeleteAccountRequest +import com.runicgateway.app.data.api.dto.DeleteAccountResultDto +import com.runicgateway.app.data.api.dto.LinkedIdentityDto +import com.runicgateway.app.data.api.dto.PlayerAccountDto +import com.runicgateway.app.data.api.dto.RecoveryCodesDto +import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest +import com.runicgateway.app.data.api.dto.RecoveryStatusDto +import com.runicgateway.app.data.api.dto.RevokedCountDto +import com.runicgateway.app.data.api.dto.RevokedFlagDto +import com.runicgateway.app.data.api.dto.TotpCodeRequest +import com.runicgateway.app.data.api.dto.TotpSetupDto +import com.runicgateway.app.data.api.dto.TotpStateDto +import com.runicgateway.app.data.api.dto.TrustDeviceRequest +import com.runicgateway.app.data.api.dto.TrustDeviceResultDto +import com.runicgateway.app.data.api.dto.TrustedDeviceDto +import com.runicgateway.app.data.api.dto.UsernameResponse +import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome +import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import retrofit2.Response +import java.io.IOException + +/** + * [AccountRepository.deleteAccount] (ACCOUNT_DELETION.md §4.1, §4.3) over a fake + * [MeApi]: each refusal the server sends becomes its own [DeleteRefusal], read off + * the body's `code`, so the screen can say which field is wrong. + */ +class AccountDeletionRepositoryTest { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + /** Only [deleteAccount] is wired; it answers [answer] and records the body. */ + private class FakeMeApi(val answer: () -> Response) : MeApi { + var lastBody: DeleteAccountRequest? = null + + override suspend fun deleteAccount(body: DeleteAccountRequest): Response { + lastBody = body + return answer() + } + + override suspend fun getAccount(): PlayerAccountDto = error("unused") + override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = error("unused") + override suspend fun changePassword(body: ChangePasswordRequest): Unit = error("unused") + override suspend fun totpSetup(): TotpSetupDto = error("unused") + override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = error("unused") + override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = error("unused") + override suspend fun identities(): List = error("unused") + override suspend fun unlinkIdentity(provider: String, id: Int?): Unit = error("unused") + override suspend fun trustedDevices(): List = error("unused") + override suspend fun trustThisDevice(body: TrustDeviceRequest): Response = error("unused") + override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = error("unused") + override suspend fun revokeAllTrustedDevices(): RevokedCountDto = error("unused") + override suspend fun recoveryCodesStatus(): RecoveryStatusDto = error("unused") + override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto = error("unused") + } + + private fun refusal(status: Int, body: String) = FakeMeApi { + Response.error(status, body.toResponseBody("application/json".toMediaTypeOrNull())) + } + + private suspend fun outcomeOf(api: MeApi) = AccountRepository(api, json).deleteAccount("Ravenna", "pw") + + @Test fun aDeletionIsDeletedAndSendsWhatItWasGiven() = runTest { + val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) } + val outcome = AccountRepository(api, json).deleteAccount("Ravenna", "pw", code = "123456") + assertEquals(DeleteOutcome.Deleted, outcome) + assertEquals(DeleteAccountRequest("Ravenna", "pw", "123456", null), api.lastBody) + } + + @Test fun aRecoveryCodeGoesInItsOwnField() = runTest { + val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) } + AccountRepository(api, json).deleteAccount("Ravenna", "pw", recoveryCode = "abcd-efgh") + assertNull(api.lastBody?.code) + assertEquals("abcd-efgh", api.lastBody?.recoveryCode) + } + + @Test fun eachRefusalCodeIsItsOwnReason() = runTest { + val cases = mapOf( + refusal(400, """{"code":"username_mismatch","message":"x"}""") to DeleteRefusal.USERNAME_MISMATCH, + refusal(400, """{"code":"wrong_password","message":"x"}""") to DeleteRefusal.WRONG_PASSWORD, + refusal(400, """{"code":"code_required","message":"x"}""") to DeleteRefusal.CODE_REQUIRED, + refusal(400, """{"code":"wrong_code","message":"x"}""") to DeleteRefusal.WRONG_CODE, + refusal(409, """{"code":"password_required","message":"x"}""") to DeleteRefusal.PASSWORD_REQUIRED, + refusal(409, """{"code":"last_admin","message":"x"}""") to DeleteRefusal.LAST_ADMIN, + refusal(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""") to + DeleteRefusal.DEMO_LOCKED, + ) + cases.forEach { (api, reason) -> assertEquals(DeleteOutcome.Refused(reason), outcomeOf(api)) } + } + + @Test fun tooManyAttemptsIsRateLimitedWhateverTheBody() = runTest { + assertEquals(DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED), outcomeOf(refusal(429, "Too many requests"))) + } + + @Test fun anUnknownOrMissingCodeIsOther() = runTest { + assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(400, """{"code":"new_rule"}"""))) + assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(500, """{"message":"boom"}"""))) + assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(502, "Bad gateway"))) + } + + @Test fun noAnswerIsANetworkError() = runTest { + assertEquals(DeleteOutcome.NetworkError, outcomeOf(FakeMeApi { throw IOException("offline") })) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt index daa014b..a0fe5b3 100644 --- a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt @@ -7,6 +7,8 @@ import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.data.api.MeApi import com.runicgateway.app.data.api.dto.ChangePasswordRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest +import com.runicgateway.app.data.api.dto.DeleteAccountRequest +import com.runicgateway.app.data.api.dto.DeleteAccountResultDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.PlayerAccountDto import com.runicgateway.app.data.api.dto.RecoveryCodesDto @@ -71,6 +73,8 @@ class AccountTrustedDevicesTest { override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto() override suspend fun identities(): List = emptyList() override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit + override suspend fun deleteAccount(body: DeleteAccountRequest): Response = + Response.success(DeleteAccountResultDto(deleted = true)) } private fun repo(api: MeApi) = AccountRepository(api, json) diff --git a/app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt b/app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt new file mode 100644 index 0000000..4b63b98 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import com.runicgateway.app.R +import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The Delete my account screen's rules (ACCOUNT_DELETION.md §4.3). */ +class AccountDeletionTest { + + @Test fun theTypedNameMatchesIgnoringCaseAndSpaces() { + assertTrue(deleteUsernameMatches("Ravenna", "Ravenna")) + assertTrue(deleteUsernameMatches("ravenna", "Ravenna")) + assertTrue(deleteUsernameMatches(" RAVENNA ", "Ravenna")) + } + + @Test fun anythingElseDoesNotMatch() { + assertFalse(deleteUsernameMatches("", "Ravenna")) + assertFalse(deleteUsernameMatches(" ", "Ravenna")) + assertFalse(deleteUsernameMatches("Raven", "Ravenna")) + assertFalse(deleteUsernameMatches("Ravenna2", "Ravenna")) + } + + @Test fun everyRoleButPlayerIsStaff() { + assertFalse(isStaffRole("player")) + assertFalse(isStaffRole("")) + assertTrue(isStaffRole("moderator")) + assertTrue(isStaffRole("editor")) + assertTrue(isStaffRole("admin")) + } + + @Test fun eachRefusalHasItsSentence() { + assertEquals(R.string.account_delete_error_username, deleteRefusalRes(DeleteRefusal.USERNAME_MISMATCH)) + assertEquals(R.string.account_delete_error_password, deleteRefusalRes(DeleteRefusal.WRONG_PASSWORD)) + assertEquals(R.string.account_delete_error_code_required, deleteRefusalRes(DeleteRefusal.CODE_REQUIRED)) + assertEquals(R.string.account_delete_error_code, deleteRefusalRes(DeleteRefusal.WRONG_CODE)) + assertEquals(R.string.account_delete_no_password, deleteRefusalRes(DeleteRefusal.PASSWORD_REQUIRED)) + assertEquals(R.string.account_delete_error_last_admin, deleteRefusalRes(DeleteRefusal.LAST_ADMIN)) + assertEquals(R.string.error_demo_locked, deleteRefusalRes(DeleteRefusal.DEMO_LOCKED)) + assertEquals(R.string.account_delete_error_rate_limited, deleteRefusalRes(DeleteRefusal.RATE_LIMITED)) + assertEquals(R.string.account_delete_error, deleteRefusalRes(DeleteRefusal.OTHER)) + } +}