Compare commits
4 Commits
v0.3.0
...
feature/tr
| Author | SHA1 | Date | |
|---|---|---|---|
| a1fa4901ef | |||
| befbc01670 | |||
| 1a14d47d5c | |||
| d6d966882b |
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import android.os.Build
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Supplies a friendly label for this device, sent as `device_name` at login so a
|
||||
* trusted-device / active-session row is recognizable in the account lists
|
||||
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
|
||||
* `android.os.Build` and unit-testable on the JVM.
|
||||
*/
|
||||
fun interface DeviceNameProvider {
|
||||
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
|
||||
fun deviceName(): String?
|
||||
}
|
||||
|
||||
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
|
||||
@Singleton
|
||||
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
|
||||
override fun deviceName(): String? {
|
||||
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
|
||||
val model = Build.MODEL?.trim().orEmpty()
|
||||
val label = when {
|
||||
model.isEmpty() -> manufacturer
|
||||
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
|
||||
else -> "$manufacturer $model"
|
||||
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
return label.take(100).ifBlank { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
|
||||
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
|
||||
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
|
||||
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
|
||||
* minted for so [tokenFor] only returns it for a matching login.
|
||||
*
|
||||
* The prefs handle is lazy so a device that never trusts pays the keystore cost
|
||||
* only if a token is actually stored or read.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedTrustTokenStore @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : TrustTokenStore {
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun tokenFor(username: String): String? {
|
||||
val token = prefs.getString(KEY_TOKEN, null) ?: return null
|
||||
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
// Case-insensitive: usernames are matched case-insensitively server-side.
|
||||
return if (owner.equals(username, ignoreCase = true)) token else null
|
||||
}
|
||||
|
||||
override fun save(username: String, token: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "runic_trust"
|
||||
const val KEY_TOKEN = "trust_token"
|
||||
const val KEY_USERNAME = "trust_username"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
/**
|
||||
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
|
||||
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
|
||||
* token skips the TOTP step on its next login (never the password).
|
||||
*
|
||||
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
|
||||
* the token must **survive logout and a dead-refresh sign-out**, because it is only
|
||||
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
|
||||
* Clearing it there would make the feature a no-op. It is scoped to the username it
|
||||
* was minted for so it is never replayed for a different account on a shared device,
|
||||
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
|
||||
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
|
||||
*
|
||||
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
|
||||
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
|
||||
*/
|
||||
interface TrustTokenStore {
|
||||
/** The stored trust token for [username], or null if this device isn't trusted for them. */
|
||||
fun tokenFor(username: String): String?
|
||||
|
||||
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
|
||||
fun save(username: String, token: String)
|
||||
|
||||
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
|
||||
fun clear()
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.SerializationException
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
@@ -37,6 +38,16 @@ inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (th
|
||||
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
|
||||
* Coroutine cancellation is rethrown so structured concurrency still works — it
|
||||
* is control flow, not a network failure.
|
||||
*
|
||||
* A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g.
|
||||
* a live-shaped `guild.update` snapshot carrying an unexpected value) throws a
|
||||
* [SerializationException] out of the Retrofit converter. That is a broken contract
|
||||
* with the backend, not a bug to crash on: the request completed but the response is
|
||||
* unusable — an invalid upstream response — so it is surfaced as a server-side error
|
||||
* (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry",
|
||||
* exactly the graceful-degradation the layer promises (never throw for an expected
|
||||
* failure). Without this catch the exception escapes the collecting coroutine and
|
||||
* takes down the whole app.
|
||||
*/
|
||||
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
ApiResult.Ok(block())
|
||||
@@ -46,4 +57,9 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
ApiResult.HttpError(e.code(), e.message())
|
||||
} catch (e: IOException) {
|
||||
ApiResult.NetworkError(e)
|
||||
} catch (e: SerializationException) {
|
||||
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
|
||||
}
|
||||
|
||||
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
|
||||
private const val MALFORMED_RESPONSE_STATUS = 502
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
@@ -27,9 +28,15 @@ import retrofit2.http.POST
|
||||
interface AuthApi {
|
||||
|
||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
||||
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
|
||||
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
|
||||
// header entirely when it is null, so an untrusted device sends nothing.
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/login")
|
||||
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
|
||||
suspend fun login(
|
||||
@Body body: MobileLoginRequest,
|
||||
@Header("X-Trust-Token") trustToken: String? = null,
|
||||
): Response<MobileTokenResponse>
|
||||
|
||||
@POST("api/v1/auth/mobile/logout")
|
||||
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||
|
||||
@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
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 retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.PATCH
|
||||
@@ -52,4 +62,28 @@ interface MeApi {
|
||||
// path template explicit alongside the provider argument.
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
|
||||
|
||||
@GET("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustedDevices(): List<TrustedDeviceDto>
|
||||
|
||||
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
|
||||
// which a thrown HttpException would discard.
|
||||
@POST("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices/{id}")
|
||||
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices")
|
||||
suspend fun revokeAllTrustedDevices(): RevokedCountDto
|
||||
|
||||
// ── Recovery (backup) codes ──────────────────────────────────────────────
|
||||
|
||||
@GET("api/v1/auth/me/account/recovery-codes/status")
|
||||
suspend fun recoveryCodesStatus(): RecoveryStatusDto
|
||||
|
||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
||||
}
|
||||
|
||||
@@ -55,9 +55,78 @@ data class TotpSetupDto(
|
||||
@Serializable
|
||||
data class TotpCodeRequest(val code: String)
|
||||
|
||||
/** Result of enabling/disabling 2FA. */
|
||||
/**
|
||||
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
|
||||
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
|
||||
* app shows them for the user to save and never persists them.
|
||||
*/
|
||||
@Serializable
|
||||
data class TotpStateDto(val totp_enabled: Boolean = false)
|
||||
data class TotpStateDto(
|
||||
val totp_enabled: Boolean = false,
|
||||
val recoveryCodes: List<String>? = null,
|
||||
)
|
||||
|
||||
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
|
||||
|
||||
/**
|
||||
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
|
||||
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
|
||||
* strings shown as-is (advisory display).
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceDto(
|
||||
val id: Long = 0,
|
||||
val platform: String? = null,
|
||||
val deviceName: String? = null,
|
||||
val userAgent: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
|
||||
@Serializable
|
||||
data class TrustDeviceRequest(val deviceName: String? = null)
|
||||
|
||||
/**
|
||||
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
|
||||
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustDeviceResultDto(
|
||||
val trusted: Boolean = false,
|
||||
val trustToken: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
|
||||
* the app lists [devices] and asks the user to revoke one, then retry.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceLimitDto(
|
||||
val error: String? = null,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
|
||||
@Serializable
|
||||
data class RevokedFlagDto(val revoked: Boolean = false)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
|
||||
@Serializable
|
||||
data class RevokedCountDto(val revoked: Int = 0)
|
||||
|
||||
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||
@Serializable
|
||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||
|
||||
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
|
||||
@Serializable
|
||||
data class RecoveryGenerateRequest(val currentPassword: String? = null)
|
||||
|
||||
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
|
||||
@Serializable
|
||||
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
|
||||
|
||||
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||
@Serializable
|
||||
|
||||
@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
|
||||
* safe (§8, recorded for M1).
|
||||
*/
|
||||
|
||||
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
|
||||
/**
|
||||
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
|
||||
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
|
||||
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
|
||||
* future logins skip the second factor — on success the response carries a
|
||||
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
|
||||
* [device_name] labels the resulting trusted-device / session row (snake_case to
|
||||
* match the backend field exactly).
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: String? = null,
|
||||
val recoveryCode: String? = null,
|
||||
val trustDevice: Boolean? = null,
|
||||
val device_name: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/mobile/refresh` body. */
|
||||
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
|
||||
/**
|
||||
* Success payload from login and refresh: the token pair, the access lifetime
|
||||
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
|
||||
*
|
||||
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
|
||||
* [trustToken] is the opaque token to persist + replay (present only when the trust
|
||||
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
|
||||
* (the login itself still succeeded). Refresh never sets these.
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileTokenResponse(
|
||||
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
|
||||
val refreshToken: String,
|
||||
val expiresIn: String? = null,
|
||||
val user: SafeUserDto,
|
||||
val trustToken: String? = null,
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||
|
||||
@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
|
||||
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||
*
|
||||
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
|
||||
* the public boards — these are separate endpoints with separate shapes.
|
||||
* In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on
|
||||
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
|
||||
*/
|
||||
|
||||
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,13 +15,18 @@ import kotlinx.serialization.json.JsonObject
|
||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||
*/
|
||||
|
||||
/** A game actor (player/leader/governor) as embedded in board payloads. */
|
||||
/**
|
||||
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
||||
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
||||
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
|
||||
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActorDto(
|
||||
val serial: Long? = null,
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val acct: String? = null,
|
||||
val webId: Long? = null,
|
||||
val webId: String? = null,
|
||||
) {
|
||||
/** Best display label for this actor. */
|
||||
val label: String get() = name ?: acct ?: "Someone"
|
||||
@@ -73,7 +78,7 @@ data class FeedEventDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class OnlineStaffDto(
|
||||
val serial: Long? = null,
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
@@ -87,7 +92,7 @@ data class OnlineStaffDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class HouseDto(
|
||||
val serial: Long = 0,
|
||||
val serial: String = "",
|
||||
val name: String? = null,
|
||||
val region: String? = null,
|
||||
val map: String? = null,
|
||||
@@ -104,7 +109,7 @@ data class HouseDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class ChampDto(
|
||||
val serial: Long = 0,
|
||||
val serial: String = "",
|
||||
val category: String? = null,
|
||||
val type: String? = null,
|
||||
val name: String? = null,
|
||||
|
||||
@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
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.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.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -26,6 +35,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val api: MeApi,
|
||||
private val json: Json,
|
||||
) {
|
||||
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||
|
||||
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||
safeApiCall { api.unlinkIdentity(provider) }
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
|
||||
|
||||
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
|
||||
safeApiCall { api.trustedDevices() }
|
||||
|
||||
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
|
||||
sealed interface TrustOutcome {
|
||||
/** Trusted; [trustToken] is the opaque token to persist (native). */
|
||||
data class Trusted(val trustToken: String?) : TrustOutcome
|
||||
|
||||
/** At the per-user cap — [devices] must be pruned before retrying. */
|
||||
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
|
||||
data object NetworkError : TrustOutcome
|
||||
data object ServerError : TrustOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust the current device. Reads the raw response so the `409 { error, devices }`
|
||||
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
|
||||
*/
|
||||
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
|
||||
val response = try {
|
||||
api.trustThisDevice(TrustDeviceRequest(deviceName))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return TrustOutcome.NetworkError
|
||||
} catch (_: Exception) {
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
return TrustOutcome.Trusted(response.body()?.trustToken)
|
||||
}
|
||||
if (response.code() == 409) {
|
||||
val devices = runCatching {
|
||||
val raw = response.errorBody()?.string()
|
||||
if (raw.isNullOrBlank()) emptyList()
|
||||
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
|
||||
}.getOrDefault(emptyList())
|
||||
return TrustOutcome.LimitReached(devices)
|
||||
}
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
|
||||
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
|
||||
safeApiCall { api.revokeTrustedDevice(id).revoked }
|
||||
|
||||
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
|
||||
safeApiCall { api.revokeAllTrustedDevices().revoked }
|
||||
|
||||
// ── Recovery (backup) codes ────────────────────────────────────────────
|
||||
|
||||
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
|
||||
safeApiCall { api.recoveryCodesStatus() }
|
||||
|
||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
@@ -12,6 +14,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
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.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -32,6 +35,8 @@ class AuthRepository @Inject constructor(
|
||||
private val ssoApi: SsoApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
@@ -73,7 +78,15 @@ class AuthRepository @Inject constructor(
|
||||
|
||||
/** Outcome of a login attempt (§4.1). */
|
||||
sealed interface LoginResult {
|
||||
data object Success : LoginResult
|
||||
/**
|
||||
* Signed in. [trustLimitReached] is true when "trust this device" was asked
|
||||
* for but the per-user cap blocked it (the login still succeeded, but no trust
|
||||
* token was issued); [devices] then lists the trusted devices to manage.
|
||||
*/
|
||||
data class Success(
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
) : LoginResult
|
||||
|
||||
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||
data object TotpRequired : LoginResult
|
||||
@@ -89,9 +102,32 @@ class AuthRepository @Inject constructor(
|
||||
data object NetworkError : LoginResult
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
|
||||
/**
|
||||
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
|
||||
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
|
||||
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
|
||||
* [trustDevice], the server may return a fresh trust token to persist for next time.
|
||||
*/
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
code: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
trustDevice: Boolean = false,
|
||||
): LoginResult {
|
||||
val storedTrustToken = trustTokenStore.tokenFor(username)
|
||||
val response: Response<MobileTokenResponse> = try {
|
||||
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
||||
authApi.login(
|
||||
MobileLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = trustDevice.takeIf { it },
|
||||
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
|
||||
),
|
||||
trustToken = storedTrustToken,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
@@ -100,8 +136,14 @@ class AuthRepository @Inject constructor(
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return LoginResult.ServerError
|
||||
// Persist a freshly minted trust token (scoped to this account) so the next
|
||||
// login skips the second factor — it deliberately outlives logout.
|
||||
body.trustToken?.let { trustTokenStore.save(username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
return LoginResult.Success
|
||||
return LoginResult.Success(
|
||||
trustLimitReached = body.trustLimitReached,
|
||||
devices = body.devices,
|
||||
)
|
||||
}
|
||||
|
||||
return when (response.code()) {
|
||||
@@ -111,6 +153,20 @@ class AuthRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a trust token minted by the self-service "trust this device" action
|
||||
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
|
||||
*/
|
||||
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
|
||||
|
||||
/**
|
||||
* Drop the locally stored trust token so this device stops skipping the TOTP step
|
||||
* (used after "untrust all" and on a Settings → Server switch). Server-side
|
||||
* revocation makes any surviving token inert anyway — the next login just prompts
|
||||
* for the code — so this is a client-side cleanliness step, never load-bearing.
|
||||
*/
|
||||
fun clearTrustToken() = trustTokenStore.clear()
|
||||
|
||||
/**
|
||||
* Revoke this session (or, with [allDevices], every session) and clear local
|
||||
* tokens (§4.3). Best-effort: the local session is torn down even if the
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.ServerUrl
|
||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||
@@ -26,6 +27,7 @@ class ConnectionRepository @Inject constructor(
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
@@ -106,6 +108,9 @@ class ConnectionRepository @Inject constructor(
|
||||
}
|
||||
pushManager.setNtfyUrl(null)
|
||||
sessionManager.onSignedOut()
|
||||
// The trust token is bound to the old host — drop it so we don't replay it
|
||||
// against a different shard (it survives a plain logout, but not a host switch).
|
||||
trustTokenStore.clear()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
*/
|
||||
package com.runicgateway.app.di
|
||||
|
||||
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||
import dagger.Binds
|
||||
@@ -25,4 +29,13 @@ abstract class StorageModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
|
||||
|
||||
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
@@ -339,12 +341,27 @@ private fun RunicNavHost(
|
||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||
onSignOut = onSignOut,
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
|
||||
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
|
||||
when (session) {
|
||||
is Session.SignedIn -> TrustedDevicesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_RECOVERY_CODES) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> RecoveryCodesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
||||
|
||||
@@ -65,6 +65,8 @@ fun AccountScreen(
|
||||
roleLabel: String,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -78,10 +80,15 @@ fun AccountScreen(
|
||||
) {
|
||||
IdentityCard(username = username, roleLabel = roleLabel)
|
||||
|
||||
// One-time recovery codes surfaced right after enabling 2FA — save them now.
|
||||
state.recoveryCodesOnce?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
|
||||
}
|
||||
|
||||
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 -> AccountSections(account.data, state, viewModel)
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
@@ -117,13 +124,34 @@ private fun AccountSections(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
) {
|
||||
UsernameSection(account, state, viewModel)
|
||||
PasswordSection(account, state, viewModel)
|
||||
TwoFactorSection(account, state, viewModel)
|
||||
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
IdentitiesSection(state, viewModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Links to the dedicated trusted-device and recovery-code screens
|
||||
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
|
||||
SectionCard(R.string.account_security_title) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenTrustedDevices,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_security_trusted_devices)) }
|
||||
OutlinedButton(
|
||||
onClick = onOpenRecoveryCodes,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.account_security_recovery_codes)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
|
||||
@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
|
||||
val busy: Boolean = false,
|
||||
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||
val totpSetup: TotpSetupDto? = null,
|
||||
/** The single-use recovery codes returned once when 2FA was just enabled. */
|
||||
val recoveryCodesOnce: List<String>? = null,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.totpEnable(code.trim())) {
|
||||
when (val result = accountRepository.totpEnable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(totpSetup = null) }
|
||||
// 2FA enable returns the fresh recovery-code batch once — surface it.
|
||||
_state.update {
|
||||
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
|
||||
}
|
||||
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||
reloadAccount()
|
||||
}
|
||||
@@ -134,6 +139,9 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
|
||||
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
|
||||
|
||||
fun disableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -123,22 +125,73 @@ fun LoginScreen(
|
||||
)
|
||||
|
||||
if (state.totpRequired) {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
singleLine = true,
|
||||
if (state.useRecoveryCode) {
|
||||
OutlinedTextField(
|
||||
value = state.recoveryCode,
|
||||
onValueChange = viewModel::onRecoveryCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_recovery_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle between authenticator code and a single-use recovery code.
|
||||
TextButton(
|
||||
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.useRecoveryCode) R.string.login_use_totp_instead
|
||||
else R.string.login_use_recovery_instead,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "Trust this device" → skip the 2FA step on future logins here.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
.padding(top = 4.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.trustDevice,
|
||||
onCheckedChange = viewModel::onTrustDeviceChange,
|
||||
enabled = !state.submitting,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.login_trust_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
|
||||
@@ -39,8 +39,14 @@ class LoginViewModel @Inject constructor(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val code: String = "",
|
||||
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
|
||||
val recoveryCode: String = "",
|
||||
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||
val totpRequired: Boolean = false,
|
||||
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
|
||||
val useRecoveryCode: Boolean = false,
|
||||
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
|
||||
val trustDevice: Boolean = false,
|
||||
val submitting: Boolean = false,
|
||||
val error: LoginError? = null,
|
||||
val signedIn: Boolean = false,
|
||||
@@ -83,6 +89,16 @@ class LoginViewModel @Inject constructor(
|
||||
fun onCodeChange(value: String) =
|
||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||
|
||||
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
|
||||
fun onRecoveryCodeChange(value: String) =
|
||||
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
|
||||
|
||||
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
|
||||
|
||||
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
|
||||
fun onUseRecoveryCodeChange(value: Boolean) =
|
||||
_state.update { it.copy(useRecoveryCode = value, error = null) }
|
||||
|
||||
val registerUrl: String? get() = websiteUrls.register()
|
||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||
|
||||
@@ -141,26 +157,42 @@ class LoginViewModel @Inject constructor(
|
||||
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
||||
return
|
||||
}
|
||||
// If 2FA is being requested, a code must accompany the resubmit.
|
||||
if (s.totpRequired && s.code.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||
return
|
||||
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
|
||||
if (s.totpRequired) {
|
||||
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
|
||||
if (factor.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() }
|
||||
when (authRepository.login(s.username.trim(), s.password, code)) {
|
||||
LoginResult.Success ->
|
||||
// Only one second factor is sent; the recovery toggle picks which.
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
|
||||
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
|
||||
val result = authRepository.login(
|
||||
username = s.username.trim(),
|
||||
password = s.password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = s.trustDevice,
|
||||
)
|
||||
when (result) {
|
||||
is LoginResult.Success ->
|
||||
// The trusted-device cap (result.trustLimitReached) is an edge case:
|
||||
// login succeeded but the device wasn't remembered. It's surfaced +
|
||||
// managed on the Trusted Devices screen rather than blocking sign-in.
|
||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
||||
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
||||
error = if (hadFactor) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.ui.UiState
|
||||
|
||||
/**
|
||||
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
|
||||
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
|
||||
* codes are shown only in memory — copy or share them before leaving; they are never
|
||||
* stored on the device.
|
||||
*/
|
||||
@Composable
|
||||
fun RecoveryCodesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RecoveryCodesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var currentPassword by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.recovery_codes_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
val remainingText = when (val r = state.remaining) {
|
||||
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
|
||||
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
|
||||
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
|
||||
}
|
||||
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
|
||||
state.freshCodes?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_current)) },
|
||||
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
|
||||
)
|
||||
|
||||
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.regenerate(currentPassword) },
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A show-once display of a freshly generated recovery-code batch, with copy/share and
|
||||
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
|
||||
*/
|
||||
@Composable
|
||||
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val joined = remember(codes) { codes.joinToString("\n") }
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.recovery_codes_new_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
codes.forEach { code ->
|
||||
Text(
|
||||
code,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(joined)) },
|
||||
) { Text(stringResource(R.string.recovery_codes_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, joined)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, null))
|
||||
},
|
||||
) { Text(stringResource(R.string.recovery_codes_share)) }
|
||||
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
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 → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
|
||||
* status and a password-stepped regenerate that surfaces a fresh single-use batch
|
||||
* **once** (never persisted). The freshly generated codes live only in memory until
|
||||
* the user leaves the screen or dismisses them.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RecoveryCodesViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
/** Remaining unused codes (the status endpoint). */
|
||||
val remaining: UiState<Int> = UiState.Loading,
|
||||
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
|
||||
val freshCodes: List<String>? = null,
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val error: Int? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(remaining = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
|
||||
fun regenerate(currentPassword: String?) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
|
||||
// Refresh the remaining count to reflect the new batch.
|
||||
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
|
||||
}
|
||||
is ApiResult.HttpError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
|
||||
is ApiResult.NetworkError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the shown-once batch from memory (user saved them / navigated away). */
|
||||
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
|
||||
}
|
||||
|
||||
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
|
||||
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
|
||||
is UiState.Success -> UiState.Success(transform(data))
|
||||
is UiState.Loading -> UiState.Loading
|
||||
is UiState.Error -> this
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/**
|
||||
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
|
||||
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
|
||||
* server re-checks ownership on every call; this screen just renders the outcomes.
|
||||
*/
|
||||
@Composable
|
||||
fun TrustedDevicesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TrustedDevicesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = stringResource(fb.messageRes),
|
||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val devices = state.devices) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> {
|
||||
if (devices.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
devices.data.forEach { device ->
|
||||
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
Button(
|
||||
onClick = viewModel::trustThisDevice,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
|
||||
|
||||
if (devices.data.isNotEmpty()) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::revokeAll,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.deviceName?.takeIf { it.isNotBlank() }
|
||||
?: device.platform?.replaceFirstChar { it.uppercase() }
|
||||
?: stringResource(R.string.trusted_devices_unknown),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
device.lastUsedAt?.let {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_last_used, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onRevoke, enabled = !busy) {
|
||||
Text(stringResource(R.string.trusted_devices_revoke))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
|
||||
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 the Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
|
||||
* allowed to skip the TOTP step, trust the current one (persisting the returned
|
||||
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
|
||||
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class TrustedDevicesViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot result banner shown above the list. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(devices = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust the current device; persist the returned token so future logins skip 2FA. */
|
||||
fun trustThisDevice() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
|
||||
is TrustOutcome.Trusted -> {
|
||||
// Bind the fresh token to the signed-in username (mirrors the login path).
|
||||
val username = sessionManager.state.value.let {
|
||||
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
|
||||
}
|
||||
if (outcome.trustToken != null && username != null) {
|
||||
authRepository.saveTrustToken(username, outcome.trustToken)
|
||||
}
|
||||
finish(true, R.string.trusted_devices_trusted)
|
||||
reload()
|
||||
}
|
||||
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
|
||||
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
|
||||
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revoke(id: Long) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.revokeTrustedDevice(id)) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(true, R.string.trusted_devices_revoked)
|
||||
reload()
|
||||
}
|
||||
else -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeAll() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.revokeAllTrustedDevices()) {
|
||||
is ApiResult.Ok -> {
|
||||
// Every device is untrusted now, including this one — drop the local token.
|
||||
authRepository.clearTrustToken()
|
||||
finish(true, R.string.trusted_devices_revoked_all)
|
||||
reload()
|
||||
}
|
||||
else -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
private suspend fun reload() {
|
||||
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
|
||||
}
|
||||
|
||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
||||
}
|
||||
@@ -18,6 +18,10 @@ object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Opt-in push notification settings (§11, signed-in). */
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ class ChampsViewModel @Inject constructor(
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
when (frame.kind) {
|
||||
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
|
||||
"champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) }
|
||||
// Serial is an opaque hex-string key ("0x…"), not a number — read as a
|
||||
// string (reading it as a Long silently dropped every champ.remove).
|
||||
"champ.remove" -> FrameFields.stringField(frame.data, "serial")?.let { board.remove(it) }
|
||||
else -> return
|
||||
}
|
||||
// Only republish when the board actually changed (Success state only).
|
||||
|
||||
@@ -69,7 +69,9 @@ class HousesViewModel @Inject constructor(
|
||||
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
if (frame.kind != "house.decay") return
|
||||
val serial = FrameFields.longField(frame.data, "serial") ?: return
|
||||
// Serials are opaque hex-string keys ("0x…"), not numbers — read as a string
|
||||
// (reading it as a Long silently dropped every live IDOC update).
|
||||
val serial = FrameFields.stringField(frame.data, "serial") ?: return
|
||||
// `to` is the new decay stage; only IDOC belongs on the public board.
|
||||
val stage = FrameFields.stringField(frame.data, "to")
|
||||
?: FrameFields.stringField(frame.data, "stage")
|
||||
|
||||
@@ -201,6 +201,49 @@
|
||||
<string name="account_identity_unlinked">Account unlinked.</string>
|
||||
<string name="account_identity_error">Couldn\'t unlink that account.</string>
|
||||
|
||||
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
|
||||
<!-- Login 2FA step -->
|
||||
<string name="login_recovery_code">Recovery code</string>
|
||||
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
|
||||
<string name="login_use_recovery_instead">Use a recovery code instead</string>
|
||||
<string name="login_use_totp_instead">Use your authenticator code instead</string>
|
||||
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
|
||||
|
||||
<!-- Account: security section -->
|
||||
<string name="account_security_title">Security</string>
|
||||
<string name="account_security_trusted_devices">Trusted devices</string>
|
||||
<string name="account_security_recovery_codes">Recovery codes</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>
|
||||
<string name="trusted_devices_empty">No trusted devices yet.</string>
|
||||
<string name="trusted_devices_unknown">Unknown device</string>
|
||||
<string name="trusted_devices_last_used">Last used %1$s</string>
|
||||
<string name="trusted_devices_revoke">Revoke</string>
|
||||
<string name="trusted_devices_trust_this">Trust this device</string>
|
||||
<string name="trusted_devices_untrust_all">Untrust all devices</string>
|
||||
<string name="trusted_devices_trusted">This device is now trusted.</string>
|
||||
<string name="trusted_devices_revoked">Device revoked.</string>
|
||||
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
|
||||
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
|
||||
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
|
||||
|
||||
<!-- Recovery codes screen -->
|
||||
<string name="recovery_codes_title">Recovery codes</string>
|
||||
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
|
||||
<string name="recovery_codes_remaining">%1$d codes remaining</string>
|
||||
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
|
||||
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
|
||||
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
|
||||
<string name="recovery_codes_regenerate">Generate new codes</string>
|
||||
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
|
||||
<string name="recovery_codes_new_title">Your new recovery codes</string>
|
||||
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
|
||||
<string name="recovery_codes_copy">Copy</string>
|
||||
<string name="recovery_codes_share">Share</string>
|
||||
<string name="recovery_codes_done">Done</string>
|
||||
|
||||
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
|
||||
<string name="player_link_title">Link your game account</string>
|
||||
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
|
||||
|
||||
@@ -35,6 +35,20 @@ class ApiResultTest {
|
||||
assertTrue(result is ApiResult.NetworkError)
|
||||
}
|
||||
|
||||
/**
|
||||
* A body the app can't decode (a field whose type doesn't match its DTO) throws a
|
||||
* [SerializationException] out of the Retrofit converter. It must degrade to a
|
||||
* server-side error the UI renders, not escape and crash the app — the guild-board
|
||||
* crash this fixes. `502` folds to [ui.ErrorKind.SERVER] via `toUiState`.
|
||||
*/
|
||||
@Test fun serializationExceptionBecomesServerError() = runTest {
|
||||
val result = safeApiCall {
|
||||
throw kotlinx.serialization.SerializationException("Unexpected symbol 'm' at path: \$[0].members")
|
||||
}
|
||||
assertTrue(result is ApiResult.HttpError)
|
||||
assertEquals(502, (result as ApiResult.HttpError).status)
|
||||
}
|
||||
|
||||
@Test fun cancellationIsRethrown() = runTest {
|
||||
assertThrows(CancellationException::class.java) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
|
||||
@@ -54,6 +54,66 @@ class AccountDtoTest {
|
||||
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
|
||||
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<TotpStateDto>(
|
||||
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
|
||||
)
|
||||
assertTrue(dto.totp_enabled)
|
||||
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun totpStateDisableHasNoRecoveryCodes() {
|
||||
// Disable (and older backends) omit the field — must decode to null, not crash.
|
||||
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
|
||||
assertFalse(dto.totp_enabled)
|
||||
assertEquals(null, dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceDecodes() {
|
||||
val dto = json.decodeFromString<TrustedDeviceDto>(
|
||||
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
|
||||
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
|
||||
"expiresAt":"2026-08-19T10:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("mobile", dto.platform)
|
||||
assertEquals("Pixel 8", dto.deviceName)
|
||||
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
|
||||
}
|
||||
|
||||
@Test fun trustDeviceResultCarriesNativeToken() {
|
||||
val dto = json.decodeFromString<TrustDeviceResultDto>(
|
||||
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
|
||||
)
|
||||
assertTrue(dto.trusted)
|
||||
assertEquals("opaque-token-abc", dto.trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceLimitDecodesDevices() {
|
||||
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
|
||||
"""{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
|
||||
)
|
||||
assertEquals("trusted_device_limit", dto.error)
|
||||
assertEquals(2, dto.devices.size)
|
||||
assertEquals(2L, dto.devices[1].id)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndCodesDecode() {
|
||||
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
|
||||
val codes = json.decodeFromString<RecoveryCodesDto>(
|
||||
"""{"recoveryCodes":["c1","c2","c3"]}""",
|
||||
)
|
||||
assertEquals(3, codes.recoveryCodes.size)
|
||||
}
|
||||
|
||||
@Test fun revokedResultsDecode() {
|
||||
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
|
||||
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
|
||||
}
|
||||
|
||||
@Test fun linkedIdentityDecodes() {
|
||||
val dto = json.decodeFromString<LinkedIdentityDto>(
|
||||
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
|
||||
|
||||
@@ -68,4 +68,35 @@ class AuthDtoTest {
|
||||
assertNull(dto.expiresIn)
|
||||
assertEquals("admin", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
|
||||
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustToken":"opaque-abc"}""",
|
||||
)
|
||||
assertEquals("opaque-abc", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
}
|
||||
|
||||
@Test fun loginSignalsTrustLimitWithDevices() {
|
||||
// At the cap: login still succeeds, but no token; the device list is returned.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
|
||||
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
|
||||
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertTrue(dto.devices.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,25 +43,31 @@ class ShardDtoTest {
|
||||
}
|
||||
|
||||
@Test fun champUpdateFrameDecodesWithKindAndExtras() {
|
||||
// A live champ.update frame: has `kind`, `serial`, and category extras. The
|
||||
// `kind` field is ignored (not on the DTO) and the extras decode.
|
||||
// A live champ.update frame: has `kind`, a hex-string `serial` (INTEGRATION.md
|
||||
// §1 — serials are opaque hex keys, never numbers), and category extras. The
|
||||
// `kind`/`rank`/`autoRestart` fields are ignored (not on the DTO); extras decode.
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon",
|
||||
"status":"active","active":true,"level":10,"maxKills":250,"kills":120,
|
||||
"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
|
||||
"""{"kind":"champ.update","serial":"0x40012345","category":"champion","name":"Barracoon",
|
||||
"status":"active","active":true,"level":10,"rank":3,"maxKills":250,"kills":120,
|
||||
"autoRestart":true,"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
|
||||
)
|
||||
assertEquals(12345L, dto.serial)
|
||||
assertEquals("0x40012345", dto.serial)
|
||||
assertEquals("champion", dto.category)
|
||||
assertEquals(120, dto.kills)
|
||||
assertTrue(dto.active)
|
||||
}
|
||||
|
||||
@Test fun guildFrameDecodesLeaderActor() {
|
||||
// The leader actor carries a hex-string serial and a string webId (the linked
|
||||
// site-user id) — the exact wire shape from INTEGRATION.md §7.
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
|
||||
"online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""",
|
||||
"online":3,"alliance":"Light",
|
||||
"leader":{"serial":"0x1A2B","name":"Arthur","acct":"art","webId":"9931","player":true}}""",
|
||||
)
|
||||
assertEquals(7L, dto.id)
|
||||
assertEquals("0x1A2B", dto.leader?.serial)
|
||||
assertEquals("9931", dto.leader?.webId)
|
||||
assertEquals("Arthur", dto.leader?.label)
|
||||
assertEquals(12, dto.members)
|
||||
}
|
||||
@@ -86,13 +92,21 @@ class ShardDtoTest {
|
||||
|
||||
@Test fun houseDecodesPublicIdocShape() {
|
||||
val dto = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":999,"name":"Tower","region":"Britain","map":"Felucca",
|
||||
"""{"serial":"0x40001234","name":"Tower","region":"Britain","map":"Felucca",
|
||||
"x":1,"y":2,"z":3,"isIdoc":true}""",
|
||||
)
|
||||
assertEquals(999L, dto.serial)
|
||||
assertEquals("0x40001234", dto.serial)
|
||||
assertTrue(dto.isIdoc)
|
||||
}
|
||||
|
||||
@Test fun onlineStaffDecodesHexSerial() {
|
||||
val dto = json.decodeFromString<OnlineStaffDto>(
|
||||
"""{"serial":"0x24C","name":"Darrow"}""",
|
||||
)
|
||||
assertEquals("0x24C", dto.serial)
|
||||
assertEquals("Darrow", dto.name)
|
||||
}
|
||||
|
||||
@Test fun actorLabelFallsBackToAcctThenSomeone() {
|
||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||
assertEquals("Someone", ActorDto().label)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
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.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.TrustOutcome
|
||||
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.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
|
||||
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
|
||||
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
|
||||
*/
|
||||
class AccountTrustedDevicesTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
|
||||
private open class FakeMeApi(
|
||||
var trustResponse: Response<TrustDeviceResultDto>? = null,
|
||||
var devices: List<TrustedDeviceDto> = emptyList(),
|
||||
var revokeFlag: Boolean = true,
|
||||
var revokeCount: Int = 0,
|
||||
var remaining: Int = 0,
|
||||
var generated: List<String> = emptyList(),
|
||||
) : MeApi {
|
||||
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
|
||||
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
|
||||
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
|
||||
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
|
||||
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
|
||||
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
|
||||
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
|
||||
RecoveryCodesDto(generated)
|
||||
|
||||
// Unused by these tests.
|
||||
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
|
||||
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
|
||||
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
|
||||
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
|
||||
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||
override suspend fun unlinkIdentity(provider: String) = Unit
|
||||
}
|
||||
|
||||
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||
|
||||
@Test fun trustThisDeviceReturnsToken() = runTest {
|
||||
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
|
||||
val outcome = repo(api).trustThisDevice("Pixel")
|
||||
assertTrue(outcome is TrustOutcome.Trusted)
|
||||
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
|
||||
val body = """{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
|
||||
.toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(409, body))
|
||||
val outcome = repo(api).trustThisDevice(null)
|
||||
assertTrue(outcome is TrustOutcome.LimitReached)
|
||||
val devices = (outcome as TrustOutcome.LimitReached).devices
|
||||
assertEquals(2, devices.size)
|
||||
assertEquals("Pixel", devices[1].deviceName)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
|
||||
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(500, body))
|
||||
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
|
||||
}
|
||||
|
||||
@Test fun revokeMapsFlagAndCount() = runTest {
|
||||
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
|
||||
assertTrue(revoked is ApiResult.Ok && revoked.data)
|
||||
|
||||
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
|
||||
assertTrue(all is ApiResult.Ok && all.data == 3)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndGenerateMap() = runTest {
|
||||
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
|
||||
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
|
||||
|
||||
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
|
||||
assertTrue(gen is ApiResult.Ok)
|
||||
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user