diff --git a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt index 49df279..1114bae 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt @@ -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 ── diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt index 9d5aaa6..507200f 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt @@ -128,10 +128,18 @@ data class RecoveryGenerateRequest(val currentPassword: String? = null) @Serializable data class RecoveryCodesDto(val recoveryCodes: List = 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, ) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt index 7282d77..c1fb891 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt @@ -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. diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt index cb5ebbc..c6702cd 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt @@ -56,8 +56,8 @@ class AccountRepository @Inject constructor( suspend fun identities(): ApiResult> = safeApiCall { api.identities() } - suspend fun unlinkIdentity(provider: String): ApiResult = - safeApiCall { api.unlinkIdentity(provider) } + suspend fun unlinkIdentity(provider: String, id: Int? = null): ApiResult = + safeApiCall { api.unlinkIdentity(provider, id) } // ── Trusted devices (TRUSTED_DEVICES_MFA.md) ─────────────────────────── diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index d3daebb..be8df3d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -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)) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt index 5d0a47d..411b310 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt @@ -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()) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa896c2..a7d4ef2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,6 +208,8 @@ Linked accounts No linked sign-in providers. Unlink + + %1$s: %2$s Account unlinked. Couldn\'t unlink that account. diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt index 4e59f44..daa014b 100644 --- a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt @@ -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 = 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)