1 Commits

Author SHA1 Message Date
c905aee94f 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
2026-10-19 20:32:54 -05:00
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.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
/** /**
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential * 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 // DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
// path template explicit alongside the provider argument. // 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}") @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 ── // ── 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 @Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList()) 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 @Serializable
data class LinkedIdentityDto( data class LinkedIdentityDto(
val id: Int? = null,
val provider: String = "", val provider: String = "",
val subject: String? = null,
val email: String? = null, val email: String? = null,
val display_name: String? = null,
val linked_at: 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 * 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 * 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 * 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. * `/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 identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> = suspend fun unlinkIdentity(provider: String, id: Int? = null): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) } safeApiCall { api.unlinkIdentity(provider, id) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ─────────────────────────── // ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────

View File

@@ -329,15 +329,18 @@ private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: A
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
val provider = identity.provider.replaceFirstChar { it.uppercase() }
Text( 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, 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) 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)) Text(stringResource(R.string.account_identity_unlink))
} }
} }

View File

@@ -158,11 +158,12 @@ class AccountViewModel @Inject constructor(
} }
// ── Identities ─────────────────────────────────────────────────────── // ── 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 if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch { viewModelScope.launch {
when (accountRepository.unlinkIdentity(provider)) { when (accountRepository.unlinkIdentity(identity.provider, identity.id)) {
is ApiResult.Ok -> { is ApiResult.Ok -> {
finish(Section.IDENTITY, true, R.string.account_identity_unlinked) finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
when (val ids = accountRepository.identities()) { when (val ids = accountRepository.identities()) {

View File

@@ -208,6 +208,8 @@
<string name="account_identities_title">Linked accounts</string> <string name="account_identities_title">Linked accounts</string>
<string name="account_identities_empty">No linked sign-in providers.</string> <string name="account_identities_empty">No linked sign-in providers.</string>
<string name="account_identity_unlink">Unlink</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_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</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 totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto() override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList() override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String) = Unit override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
} }
private fun repo(api: MeApi) = AccountRepository(api, json) private fun repo(api: MeApi) = AccountRepository(api, json)