Stop a status refresh from erasing a rejected credential

The API layer marking an account `credentialRejected` posts the same
notification that recomputes statuses, which saw the Keychain item still
present and immediately reported "signed in" again. The rejection could
therefore never survive the moment it was recorded, and the user was
never asked to sign in again. Only the server can clear a rejection now;
storing a new credential resets it explicitly.

Cache invalidation follows the effective context rather than the stored
one. A credential arriving or disappearing flips reads between the
account and local without the stored context moving, so the global feed
caches kept whichever side had populated them.

The login sheet stays open when the credential could not be stored — an
unresolved identity scope or a Keychain failure — instead of dismissing
and discarding what the user typed.
This commit is contained in:
Arkadiusz Fal
2026-08-26 19:27:28 +02:00
parent 923e13565f
commit 4f617b52e1
3 changed files with 55 additions and 29 deletions

View File

@@ -389,8 +389,11 @@ private struct EditRemoteServerContent: View {
///
/// The identifier is stored beside the credential in the Keychain rather
/// than on the account record, so it never travels as synced metadata.
private func storeLogin(credential: String, username: String, target: AccountLoginTarget) {
guard let appEnvironment else { return }
/// - Returns: whether the credential was stored. A login the server
/// accepted is not the same as one this device could keep.
@discardableResult
private func storeLogin(credential: String, username: String, target: AccountLoginTarget) -> Bool {
guard let appEnvironment else { return false }
// Credential storage refuses to write under an unresolved identity
// scope, so creating the account first would leave one with no
@@ -400,13 +403,13 @@ private struct EditRemoteServerContent: View {
appEnvironment.toastManager.showError(
String(localized: "accounts.signIn.identityUnresolved")
)
return
return false
}
let account = target.account
?? appEnvironment.accountsManager.addAccount(instanceID: instance.id)
guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { return }
guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { return false }
credentials.setCredential(credential, for: account)
@@ -420,7 +423,7 @@ private struct EditRemoteServerContent: View {
appEnvironment.toastManager.showError(
String(localized: "accounts.signIn.storeFailed")
)
return
return false
}
if !username.isEmpty {
credentials.setUsername(username, for: account)
@@ -442,6 +445,7 @@ private struct EditRemoteServerContent: View {
appEnvironment.accountCredentialDidChange(for: account)
isLoggedIn = true
return true
}
private func loadServerInfo() async {

View File

@@ -9,13 +9,18 @@ import SwiftUI
struct InstanceLoginView: View {
let instance: Instance
/// Receives the credential **and** the identifier the user signed in with.
/// Receives the credential **and** the identifier the user signed in with,
/// and reports whether it was stored.
///
/// The identifier is handed back rather than stored here because it belongs
/// beside the credential in the Keychain, not in synced account metadata —
/// so only the caller, which knows which account this login is for, can
/// file it correctly.
let onLoginSuccess: (_ credential: String, _ username: String) -> Void
///
/// The return value matters because signing in successfully is not the same
/// as having somewhere to keep the result: dismissing on a Keychain failure
/// would throw away what the user typed and make them do it all again.
let onLoginSuccess: (_ credential: String, _ username: String) -> Bool
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@@ -153,7 +158,13 @@ struct InstanceLoginView: View {
let credential = try await performLogin(appEnvironment: appEnvironment)
await MainActor.run {
onLoginSuccess(credential, username)
guard onLoginSuccess(credential, username) else {
// The failure is reported by a toast from the storing
// side; staying open keeps the form filled in so the
// user can retry once it can succeed.
isLoading = false
return
}
dismiss()
}
} catch APIError.unauthorized {
@@ -213,13 +224,13 @@ struct InstanceLoginView: View {
#Preview("Invidious") {
InstanceLoginView(
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
) { _, _ in }
) { _, _ in true }
.appEnvironment(.preview)
}
#Preview("Piped") {
InstanceLoginView(
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
) { _, _ in }
) { _, _ in true }
.appEnvironment(.preview)
}