feat(account): several Steam accounts on the account screen
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m57s

Steam sign-in (docs/website/STEAM_SIGNIN.md) lets a person link more than one
Steam account. Unlink went by provider, so it would have removed all of them;
it now sends the identity's id (DELETE …/identities/:provider?id=). A Steam row
reads "Steam: <persona name>" with its SteamID64 beneath, since Steam sends no
email. The sign-in sheet already lists providers from /auth/providers, so Steam
appears there with no change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-19 20:32:54 -05:00
parent 8e7a41f053
commit c905aee94f
8 changed files with 28 additions and 11 deletions

View File

@@ -27,6 +27,7 @@ import retrofit2.http.HTTP
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential
@@ -60,8 +61,10 @@ interface MeApi {
// DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
// path template explicit alongside the provider argument.
// `id` (optional) removes that one identity rather than every one of the
// provider — a person may link several Steam accounts (STEAM_SIGNIN.md).
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
suspend fun unlinkIdentity(@Path("provider") provider: String, @Query("id") id: Int? = null): Unit
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──

View File

@@ -128,10 +128,18 @@ data class RecoveryGenerateRequest(val currentPassword: String? = null)
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
/** A linked external identity (`GET /auth/me/account/identities`). */
/**
* A linked external identity (`GET /auth/me/account/identities`). [id] unlinks this
* one identity when a provider has several (a person may link more than one Steam
* account); [subject] is the person's own external id — for Steam the SteamID64 —
* and [display_name] the Steam persona name (null for other providers).
*/
@Serializable
data class LinkedIdentityDto(
val id: Int? = null,
val provider: String = "",
val subject: String? = null,
val email: String? = null,
val display_name: String? = null,
val linked_at: String? = null,
)

View File

@@ -16,7 +16,7 @@ import kotlinx.serialization.Serializable
/**
* One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is
* the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a
* the provider kind (`google` | `discord` | `steam` | `oidc` | `oauth2`); the app renders a
* button per provider from this list rather than hardcoding a set. [loginUrl] is
* the *website* start path (unused by the app, which builds its own
* `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly.

View File

@@ -56,8 +56,8 @@ class AccountRepository @Inject constructor(
suspend fun identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) }
suspend fun unlinkIdentity(provider: String, id: Int? = null): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider, id) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────

View File

@@ -329,15 +329,18 @@ private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: A
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
val provider = identity.provider.replaceFirstChar { it.uppercase() }
Text(
identity.provider.replaceFirstChar { it.uppercase() },
// Steam: "Steam: CoolGuy99" — a person may hold several, so the name tells them apart.
identity.display_name?.let { stringResource(R.string.account_identity_named, provider, it) } ?: provider,
style = MaterialTheme.typography.bodyLarge,
)
identity.email?.let {
// Steam sends no email; its SteamID64 identifies the account instead.
(identity.email ?: identity.subject?.takeIf { identity.provider == "steam" })?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) {
TextButton(onClick = { viewModel.unlinkIdentity(identity) }, enabled = !busy) {
Text(stringResource(R.string.account_identity_unlink))
}
}

View File

@@ -158,11 +158,12 @@ class AccountViewModel @Inject constructor(
}
// ── Identities ───────────────────────────────────────────────────────
fun unlinkIdentity(provider: String) {
/** Unlink [identity] alone — by its id, so one of several Steam accounts goes and the rest stay. */
fun unlinkIdentity(identity: LinkedIdentityDto) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.unlinkIdentity(provider)) {
when (accountRepository.unlinkIdentity(identity.provider, identity.id)) {
is ApiResult.Ok -> {
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
when (val ids = accountRepository.identities()) {

View File

@@ -208,6 +208,8 @@
<string name="account_identities_title">Linked accounts</string>
<string name="account_identities_empty">No linked sign-in providers.</string>
<string name="account_identity_unlink">Unlink</string>
<!-- A provider that reports a name (Steam): "Steam: CoolGuy99". -->
<string name="account_identity_named">%1$s: %2$s</string>
<string name="account_identity_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</string>

View File

@@ -70,7 +70,7 @@ class AccountTrustedDevicesTest {
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
override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
}
private fun repo(api: MeApi) = AccountRepository(api, json)