Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c905aee94f |
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user