feat(account): several Steam accounts on the account screen
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m57s
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:
@@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) ───────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user