Merge pull request 'feat(account): delete my account in the app' (#63) from feat/account-deletion into edge

Reviewed-on: #63
This commit is contained in:
2026-10-30 05:27:57 +00:00
14 changed files with 731 additions and 0 deletions

View File

@@ -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<DeleteAccountResultDto>
}

View File

@@ -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)

View File

@@ -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<RecoveryCodesDto> =
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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()) {

View File

@@ -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),
)
}
}

View File

@@ -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<PlayerAccountDto> = 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> = _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) }
}
}
}
}

View File

@@ -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.
*

View File

@@ -197,6 +197,38 @@
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Delete my account (ACCOUNT_DELETION.md §4.3). The same words as the website's lib/accountDeletion.js. -->
<string name="account_delete_title">Delete my account</string>
<string name="account_delete_row_hint">Deletes your account straight away. It cannot be restored.</string>
<string name="account_delete_open">Delete my account…</string>
<string name="account_delete_intro">Your account is deleted straight away and cannot be restored. These go with it:</string>
<string name="account_delete_goes_sign_in">your sign-in, password, email address, two-factor settings and recovery codes</string>
<string name="account_delete_goes_devices">every signed-in device, trusted device and push registration</string>
<string name="account_delete_goes_identities">your linked sign-ins, such as Google, Discord or Steam</string>
<string name="account_delete_goes_game_links">your game-account links, and anything this site granted in the game through them</string>
<string name="account_delete_goes_notifications">your notification settings and inbox</string>
<string name="account_delete_stays_title">What stays:</string>
<string name="account_delete_stays_content">What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.</string>
<string name="account_delete_stays_reports">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.</string>
<string name="account_delete_stays_events">Event results stay with the score and the in-game name, with no site account behind them.</string>
<string name="account_delete_stays_game_account">A game account stays on the game server. Only its link to this site goes.</string>
<string name="account_delete_staff_note">You are staff, so your username can never be used again, by anyone, once this account is deleted.</string>
<string name="account_delete_no_password">This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion.</string>
<string name="account_delete_type_username">Type %1$s to confirm</string>
<string name="account_delete_recovery_code">Recovery code</string>
<string name="account_delete_use_recovery">Use a recovery code instead</string>
<string name="account_delete_use_authenticator">Use an authenticator code instead</string>
<string name="account_delete_action">Delete my account</string>
<string name="account_delete_busy">Deleting…</string>
<string name="account_delete_done">Your account has been deleted.</string>
<string name="account_delete_error_username">Type your username exactly as it is shown.</string>
<string name="account_delete_error_password">Your password is incorrect.</string>
<string name="account_delete_error_code_required">Enter a code from your authenticator app, or a recovery code.</string>
<string name="account_delete_error_code">That code is not valid. Try again.</string>
<string name="account_delete_error_last_admin">You are the only admin. Make someone else an admin before you delete your account.</string>
<string name="account_delete_error_rate_limited">Too many attempts. Wait a few minutes and try again.</string>
<string name="account_delete_error">Could not delete your account. Try again.</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>

View File

@@ -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<DeleteAccountResultDto>) : MeApi {
var lastBody: DeleteAccountRequest? = null
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> {
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<LinkedIdentityDto> = error("unused")
override suspend fun unlinkIdentity(provider: String, id: Int?): Unit = error("unused")
override suspend fun trustedDevices(): List<TrustedDeviceDto> = error("unused")
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> = 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, "<html>Bad gateway</html>")))
}
@Test fun noAnswerIsANetworkError() = runTest {
assertEquals(DeleteOutcome.NetworkError, outcomeOf(FakeMeApi { throw IOException("offline") }))
}
}

View File

@@ -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<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> =
Response.success(DeleteAccountResultDto(deleted = true))
}
private fun repo(api: MeApi) = AccountRepository(api, json)

View File

@@ -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))
}
}