Files
yattee/Yattee/Views/Settings/InstanceAccountsSection.swift
Arkadiusz Fal ae2fee2d4c Make a second login on an instance reachable and orderable
An additional login could be created but never selected: the legacy
picker lists one entry per instance and always resolved to whichever
account signed in first. Account rows gain a "Use This Account" action,
and the subscriptions view reloads on the data context rather than on
the legacy setting — two logins on the same instance map onto the same
legacy value, so switching between them changed nothing on screen while
writes went to the other account.

Two devices migrating different legacy selections produced contexts with
identical epoch timestamps, and each refused the other's: a permanent,
silent divergence that both sides considered settled. Equal timestamps
are now broken by the selected account's ID, on the incoming side and in
the conflict resolver alike — a record that wins the upload must not be
refused on arrival.

Credential cleanup falls back to both managers whenever the backend
cannot be resolved, not only when the account is unknown here: an
instance deletion can arrive before the tombstone, and the credential
was then left in the Keychain.
2026-08-26 22:59:52 +02:00

214 lines
7.8 KiB
Swift

//
// InstanceAccountsSection.swift
// Yattee
//
// The per-instance account list shown in EditSourceView.
//
import SwiftUI
/// Which account a login sheet is for. `account == nil` means "create a new one".
struct AccountLoginTarget: Identifiable {
let id: UUID
let account: Account?
static let newAccount = AccountLoginTarget(id: UUID(), account: nil)
static func existing(_ account: Account) -> AccountLoginTarget {
AccountLoginTarget(id: account.id, account: account)
}
}
/// Lists the accounts signed in on one instance.
///
/// Replaces the single "Log in / Log out" pair: an instance can now hold
/// several logins, each with its own credential and status.
struct InstanceAccountsSection: View {
let instance: Instance
@Binding var loginTarget: AccountLoginTarget?
@Environment(\.appEnvironment) private var appEnvironment
private var accounts: [Account] {
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
}
var body: some View {
Section {
ForEach(accounts) { account in
accountRow(account)
}
Button {
loginTarget = .newAccount
} label: {
Label(
String(localized: accounts.isEmpty ? "login.loginToAccount" : "accounts.addAccount"),
systemImage: "person.badge.plus"
)
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
} header: {
Text(String(localized: "login.header.account"))
} footer: {
Text(String(localized: accounts.isEmpty ? "login.footer.loginBenefits" : "login.footer.loggedIn"))
}
adoptionSection
}
// MARK: - Rows
@ViewBuilder
private func accountRow(_ account: Account) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(displayName(for: account))
.lineLimit(1)
Text(statusText(for: account))
.font(.caption)
.foregroundStyle(statusColor(for: account))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu {
// Without this a second login on an instance cannot be reached: the
// legacy picker lists one entry per instance, so it always resolves
// to whichever account was signed in first. The switcher that makes
// this a first-class choice everywhere comes later; until then this
// is what makes a second login usable at all.
if !isCurrentContext(account) {
Button {
appEnvironment?.accountsManager.setContext(.account(account.id))
} label: {
Label(String(localized: "accounts.useThisAccount"), systemImage: "checkmark.circle")
}
}
Button {
loginTarget = .existing(account)
} label: {
Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key")
}
Button(role: .destructive) {
signOut(account)
} label: {
Label(String(localized: "login.signOutAndRemove"), systemImage: "rectangle.portrait.and.arrow.forward")
}
}
#if os(tvOS)
// tvOS never syncs app Keychain items, so an account that arrived from
// another device is permanently credential-less here until the user
// signs in on the Apple TV itself. Offer that directly rather than
// leaving a status the user cannot act on.
if status(for: account) == .credentialMissing {
Button {
loginTarget = .existing(account)
} label: {
Label(String(localized: "accounts.signInOnThisDevice"), systemImage: "person.badge.key")
}
.buttonStyle(TVSettingsButtonStyle())
}
#endif
}
/// The one-tap adoption of a login saved by an earlier version.
///
/// Shown only when such an item exists and has not been adopted. It is a
/// deliberate action because a retained Keychain item carries no proof of
/// which Apple Account it belonged to — the app cannot tell an inherited
/// credential from the user's own.
@ViewBuilder
private var adoptionSection: some View {
let adoptable = accounts.filter { account in
appEnvironment?
.accountCredentialsManager(for: account)?
.hasAdoptableLegacyCredential(for: account) == true
}
if !adoptable.isEmpty {
Section {
ForEach(adoptable) { account in
Button {
adoptLegacyCredential(for: account)
} label: {
Label(String(localized: "accounts.useSavedLogin"), systemImage: "key.horizontal")
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
} footer: {
Text(String(localized: "accounts.useSavedLogin.footer"))
}
}
}
// MARK: - Presentation
private func displayName(for account: Account) -> String {
appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName
}
private func status(for account: Account) -> AccountStatus {
appEnvironment?.accountsManager.status(for: account.id) ?? .credentialMissing
}
private func statusText(for account: Account) -> String {
switch status(for: account) {
case .signedIn: String(localized: "accounts.status.signedIn")
case .credentialMissing: String(localized: "accounts.status.credentialMissing")
case .credentialRejected: String(localized: "accounts.status.credentialRejected")
case .unreachable: String(localized: "accounts.status.unreachable")
}
}
private func isCurrentContext(_ account: Account) -> Bool {
appEnvironment?.accountsManager.currentContext == .account(account.id)
}
private func statusColor(for account: Account) -> Color {
switch status(for: account) {
case .signedIn: .secondary
case .credentialMissing: .secondary
case .credentialRejected: .red
case .unreachable: .orange
}
}
// MARK: - Actions
private func signOut(_ account: Account) {
guard let appEnvironment else { return }
// Deleting the account is what authorizes removing the legacy item, and
// only when this device adopted it and it still holds that same secret.
appEnvironment.accountCredentialsManager(for: account)?.deleteCredential(for: account)
appEnvironment.accountCredentialsManager(for: account)?.deleteLegacyCredentialIfOwned(for: account)
appEnvironment.accountsManager.removeAccount(account.id)
// Drop the provider too, or its in-memory subscription cache would keep
// answering for an account that no longer exists.
appEnvironment.subscriptionService.discardProvider(for: account.id)
if appEnvironment.accountsManager.accounts(forInstance: instance.id).isEmpty {
appEnvironment.instancesManager.setUsesAccountLogin(false, for: instance)
}
}
private func adoptLegacyCredential(for account: Account) {
guard let appEnvironment,
let credentials = appEnvironment.accountCredentialsManager(for: account)
else { return }
if credentials.adoptLegacyCredential(for: account) {
appEnvironment.accountsManager.setStatus(.signedIn, for: account.id)
} else {
appEnvironment.toastManager.showError(
String(localized: "accounts.useSavedLogin.failed")
)
}
}
}