feat(account): delete my account in the app
All checks were successful
PR Checks / android-build (pull_request) Successful in 14m55s
All checks were successful
PR Checks / android-build (pull_request) Successful in 14m55s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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.ChangePasswordRequest
|
||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
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.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||||
@@ -89,4 +91,11 @@ interface MeApi {
|
|||||||
|
|
||||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
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
|
@Serializable
|
||||||
data class RevokedCountDto(val revoked: Int = 0)
|
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. */
|
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
package com.runicgateway.app.data.repository
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
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.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
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. */
|
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
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.TotpRequiredError
|
||||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -208,6 +210,28 @@ class AuthRepository @Inject constructor(
|
|||||||
sessionManager.onSignedOut()
|
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
|
* 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
|
* 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.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.data.repository.Capability
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.ui.auth.AccountScreen
|
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.LoginScreen
|
||||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||||
@@ -204,6 +206,7 @@ fun RunicApp(
|
|||||||
// the one showing rather than queueing behind it.
|
// the one showing rather than queueing behind it.
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val demoLockedFallback = stringResource(R.string.error_demo_locked)
|
val demoLockedFallback = stringResource(R.string.error_demo_locked)
|
||||||
|
val accountDeletedNotice = stringResource(R.string.account_delete_done)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
demoNoticeViewModel.refusals.collectLatest { refusal ->
|
demoNoticeViewModel.refusals.collectLatest { refusal ->
|
||||||
snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback)
|
snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback)
|
||||||
@@ -417,6 +420,15 @@ fun RunicApp(
|
|||||||
session = session,
|
session = session,
|
||||||
onSignOut = { sessionViewModel.signOut() },
|
onSignOut = { sessionViewModel.signOut() },
|
||||||
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
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),
|
modifier = Modifier.padding(innerPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -527,6 +539,7 @@ private fun RunicNavHost(
|
|||||||
session: Session,
|
session: Session,
|
||||||
onSignOut: () -> Unit,
|
onSignOut: () -> Unit,
|
||||||
onSignOutEverywhere: () -> Unit,
|
onSignOutEverywhere: () -> Unit,
|
||||||
|
onAccountDeleted: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -709,6 +722,7 @@ private fun RunicNavHost(
|
|||||||
onSignOutEverywhere = onSignOutEverywhere,
|
onSignOutEverywhere = onSignOutEverywhere,
|
||||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||||
|
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
|
||||||
)
|
)
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||||
navController.navigateTopLevel(Routes.HOME)
|
navController.navigateTopLevel(Routes.HOME)
|
||||||
@@ -728,6 +742,18 @@ private fun RunicNavHost(
|
|||||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
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) {
|
composable(Routes.NOTIFICATIONS) {
|
||||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
// 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
|
// 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.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -67,6 +69,7 @@ fun AccountScreen(
|
|||||||
onSignOutEverywhere: () -> Unit,
|
onSignOutEverywhere: () -> Unit,
|
||||||
onOpenTrustedDevices: () -> Unit,
|
onOpenTrustedDevices: () -> Unit,
|
||||||
onOpenRecoveryCodes: () -> Unit,
|
onOpenRecoveryCodes: () -> Unit,
|
||||||
|
onOpenDeleteAccount: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AccountViewModel = hiltViewModel(),
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -102,9 +105,40 @@ fun AccountScreen(
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.account_sign_out_all))
|
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
|
@Composable
|
||||||
private fun IdentityCard(username: String, roleLabel: String) {
|
private fun IdentityCard(username: String, roleLabel: String) {
|
||||||
ShardCard(Modifier.fillMaxWidth()) {
|
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_TRUSTED_DEVICES = "account/trusted-devices"
|
||||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
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.
|
* 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_trusted_devices">Trusted devices</string>
|
||||||
<string name="account_security_recovery_codes">Recovery codes</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 -->
|
<!-- Trusted devices screen -->
|
||||||
<string name="trusted_devices_title">Trusted devices</string>
|
<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>
|
<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.MeApi
|
||||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
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.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
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 totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||||
override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
|
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)
|
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