feat(account): delete my account in the app #63
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") }))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user