mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
An account whose source is gone was listed nowhere: `orphanedAccounts` had no readers, and the credential behind it could not be reached at all — `AppEnvironment.accountCredentialsManager(for:)` resolves the store from the account's instance, and an orphan has none. The new accounts screen lists them and removes them by account ID, asking every account-keyed store because the backend is no longer knowable. The pre-revamp instance-keyed item is deliberately left behind: identifying it needs the instance too. Deleting a source has always signed out and removed the accounts on it. Both confirmations now say so, and name them. The sign-in that files a credential against an account is now one implementation instead of a private method on the source editor, so the accounts screen and the new optional sign-in step after adding a source attach a login exactly as the editor does. Extracted verbatim — the identity-scope guard, the account re-resolve, the write-then-read-back and the rollback are what keep a half-written login from looking real. Signing out from the per-instance section now asks first, and both sign-out paths run one cascade rather than two copies of it.
186 lines
6.9 KiB
Swift
186 lines
6.9 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
|
|
|
|
/// Bumped whenever a credential is replaced, purely to re-read the login
|
|
/// identifier the rows display.
|
|
///
|
|
/// That identifier lives in the Keychain beside the secret, not in any
|
|
/// observable state, so signing in again as somebody else moves nothing
|
|
/// SwiftUI watches — same account, same context, and the status was
|
|
/// already `signedIn`. Without this the row keeps naming the previous
|
|
/// person while the app is already serving the new one's data, which is
|
|
/// exactly backwards: the label is the only thing telling two logins on
|
|
/// one instance apart.
|
|
@State private var credentialRevision = 0
|
|
|
|
/// The account a sign-out was asked for, held until the confirmation comes
|
|
/// back. Removing an account deletes its credential from every device
|
|
/// sharing the Keychain, which is not something to do on one tap.
|
|
@State private var pendingSignOut: Account?
|
|
|
|
private var accounts: [Account] {
|
|
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
|
|
}
|
|
|
|
var body: some View {
|
|
Section {
|
|
ForEach(accounts) { account in
|
|
accountRow(account)
|
|
}
|
|
// Read here on purpose, so the rows genuinely depend on it rather
|
|
// than relying on a state write invalidating a body that never
|
|
// mentions it.
|
|
.id(credentialRevision)
|
|
|
|
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"))
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
|
credentialRevision &+= 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
@ViewBuilder
|
|
private func accountRow(_ account: Account) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(displayName(for: account))
|
|
.lineLimit(1)
|
|
|
|
AccountStatusBadge(status: status(for: account))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contextMenu {
|
|
// Selecting an account from the source that owns it, rather than
|
|
// hunting for it in the global switcher.
|
|
if !isCurrentContext(account) {
|
|
Button {
|
|
appEnvironment?.switchDataContext(to: .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) {
|
|
pendingSignOut = account
|
|
} label: {
|
|
Label(String(localized: "login.signOutAndRemove"), systemImage: "rectangle.portrait.and.arrow.forward")
|
|
}
|
|
}
|
|
// Attached to the row that raised it rather than to the section: a
|
|
// presentation modifier on a `Section` inside a `Form` has no view of
|
|
// its own to present from.
|
|
.confirmationDialog(
|
|
String(localized: "accounts.remove.confirm.title \(displayName(for: account))"),
|
|
isPresented: Binding(
|
|
// By ID: a label edit arriving from another device while the
|
|
// dialog is up would otherwise close it.
|
|
get: { pendingSignOut?.id == account.id },
|
|
set: { if !$0 { pendingSignOut = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.remove"), role: .destructive) {
|
|
confirmSignOut()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
pendingSignOut = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "accounts.remove.confirm.message"))
|
|
}
|
|
#if !os(tvOS)
|
|
.presentationCompactAdaptation(.sheet)
|
|
#endif
|
|
|
|
#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
|
|
}
|
|
|
|
// 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) ?? .checking
|
|
}
|
|
|
|
private func isCurrentContext(_ account: Account) -> Bool {
|
|
appEnvironment?.accountsManager.currentContext == .account(account.id)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func confirmSignOut() {
|
|
guard let appEnvironment, let account = pendingSignOut else { return }
|
|
defer { pendingSignOut = nil }
|
|
|
|
// Re-resolved rather than trusted: the dialog can outlive the account,
|
|
// which another device may have deleted while it was up.
|
|
guard let account = appEnvironment.accountsManager.account(id: account.id) else { return }
|
|
|
|
appEnvironment.signOutAndRemoveAccount(account)
|
|
}
|
|
}
|