Let accounts be set inactive without signing them out

An inactive account keeps its credential and syncs like a rename, but
drops out of every picker and cannot be used until activated again.
Deactivating the selected account switches to the local context as a
published choice, so other devices follow instead of falling back.
This commit is contained in:
Arkadiusz Fal
2026-09-19 23:58:24 +02:00
parent 01ff78aaa6
commit 794b2b0181
10 changed files with 160 additions and 7 deletions

View File

@@ -29,6 +29,11 @@ enum AccountStatus: Hashable, Sendable {
/// turned on again — but nothing may be read from or written to it
/// meanwhile, so no surface may present the account as usable.
case instanceDisabled
/// The user set the account itself to inactive. Like `instanceDisabled`
/// this is not a credential state — the login is untouched and comes back
/// the moment the account is activated again — and only `displayStatus`
/// ever reports it.
case inactive
/// The server could not be reached. Never invalidates a credential.
case unreachable
}
@@ -189,9 +194,11 @@ final class AccountsManager {
/// Accounts that can be selected as the current context right now.
///
/// Excludes accounts still waiting for their instance: showing a context
/// the app cannot actually resolve would be a dead end.
/// the app cannot actually resolve would be a dead end. Inactive accounts
/// are excluded too — the user set them aside, and the pickers must not
/// keep offering what was deliberately switched off.
var selectableAccounts: [Account] {
accounts.filter { capabilities(for: $0)?.isSelectableContext == true }
accounts.filter { $0.isActive && capabilities(for: $0)?.isSelectableContext == true }
}
/// Accounts whose instance is gone.
@@ -252,6 +259,31 @@ final class AccountsManager {
NotificationCenter.default.post(name: .accountsDidChange, object: nil)
}
/// Sets whether the account is active, syncing the change like a rename.
///
/// A write that changes nothing is dropped for the same reason
/// `updateDisplayName` drops one: every real write stamps `updatedAt`, and
/// under last-write-wins an idle stamp would overwrite a peer's genuine
/// edit that has not arrived yet.
func updateIsActive(_ isActive: Bool, for accountID: UUID) {
guard let index = accounts.firstIndex(where: { $0.id == accountID }) else { return }
guard accounts[index].isActive != isActive else { return }
accounts[index].isActive = isActive
accounts[index].updatedAt = Date()
pendingUploads.insert(accountID)
save()
NotificationCenter.default.post(name: .accountsDidChange, object: nil)
// Deactivating the selected account moves the selection to local — as
// a real choice, not a repair: the user just said "stop using this",
// and unlike a deletion there is no ambiguity for a sibling to settle.
// Publishing it keeps every device's selection off the account the
// user set aside.
if !isActive, storedContext.context == .account(accountID) {
setContext(.local)
}
}
/// Removes an account and everything keyed to it.
///
/// The deletion is recorded in the outbox before anything else so that a
@@ -417,6 +449,7 @@ final class AccountsManager {
refreshStatusesForRepair?()
let sibling = accounts(forInstance: instanceID)
.filter(\.isActive)
.filter { status(for: $0.id) == .signedIn }
.filter { capabilities(for: $0)?.isSelectableContext == true }
// Ordered so the answer does not depend on which order the accounts
@@ -875,6 +908,7 @@ final class AccountsManager {
/// app claiming health it will not deliver.
func displayStatus(for account: Account) -> AccountStatus {
guard instance(for: account)?.isEnabled != false else { return .instanceDisabled }
guard account.isActive else { return .inactive }
return status(for: account.id)
}
@@ -1195,8 +1229,13 @@ final class AccountsManager {
// path, so it is marked dirty and ordered by `updatedAt` like any other
// change — nothing else about the record is taken.
if let existing = self.account(id: account.id) {
if account.updatedAt > existing.updatedAt, account.displayName != existing.displayName {
updateDisplayName(account.displayName, for: account.id)
if account.updatedAt > existing.updatedAt {
if account.displayName != existing.displayName {
updateDisplayName(account.displayName, for: account.id)
}
if account.isActive != existing.isActive {
updateIsActive(account.isActive, for: account.id)
}
}
return .mergedIntoExisting
}

View File

@@ -139,6 +139,17 @@
},
"1.2M subscribers" : {
},
"accounts.activate" : {
"comment" : "Action putting an inactive account back in use",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Activate"
}
}
}
},
"accounts.addAccount" : {
"localizations" : {
@@ -194,6 +205,17 @@
}
}
},
"accounts.deactivate" : {
"comment" : "Action setting an account inactive without signing it out",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Deactivate"
}
}
}
},
"accounts.empty.description" : {
"localizations" : {
"en" : {
@@ -439,6 +461,17 @@
}
}
},
"accounts.status.inactive" : {
"comment" : "Status of an account the user set to inactive",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Inactive"
}
}
}
},
"accounts.status.instanceDisabled" : {
"comment" : "Status of an account whose source has been switched off",
"localizations" : {
@@ -18159,6 +18192,17 @@
}
}
},
"subscriptions.banner.accountInactive" : {
"comment" : "Banner shown while the selected account cannot be used yet",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This account is set to inactive. Showing local data."
}
}
}
},
"subscriptions.banner.checking" : {
"comment" : "Banner shown while the selected account cannot be used yet",
"localizations" : {

View File

@@ -28,6 +28,14 @@ struct Account: Identifiable, Codable, Hashable, Sendable {
/// User-set label. The only name that syncs.
var displayName: String?
/// Whether the user wants this account in use at all.
///
/// Deactivating is deliberately not a credential state: the login stays in
/// the Keychain untouched, and reactivating restores whatever it really
/// was. While inactive the account cannot be switched to and the selection
/// falls back to local — the account-level twin of a switched-off source.
var isActive: Bool
let dateAdded: Date
/// Bumped on every user edit — supplies the ordering for conflict
@@ -38,16 +46,31 @@ struct Account: Identifiable, Codable, Hashable, Sendable {
id: UUID = UUID(),
instanceID: UUID,
displayName: String? = nil,
isActive: Bool = true,
dateAdded: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.instanceID = instanceID
self.displayName = displayName
self.isActive = isActive
self.dateAdded = dateAdded
self.updatedAt = updatedAt
}
/// Decoded by hand for one reason: accounts persisted before `isActive`
/// existed carry no such key, and they are all active — that was the only
/// state there was.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
instanceID = try container.decode(UUID.self, forKey: .instanceID)
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? true
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
/// The account synthesized for an instance that was signed in before
/// multi-account support existed.
///

View File

@@ -401,6 +401,12 @@ actor CloudKitConflictResolver {
if localWins {
resolved["displayName"] = local["displayName"]
// A local record built before the field existed carries no value;
// clearing the server's would flip the account back to active, so
// absence leaves the server copy standing.
if local["isActive"] != nil {
resolved["isActive"] = local["isActive"]
}
resolved["updatedAt"] = local["updatedAt"]
}

View File

@@ -909,6 +909,7 @@ final class CloudKitRecordMapper {
record["accountID"] = account.id.uuidString as CKRecordValue
record["instanceID"] = account.instanceID.uuidString as CKRecordValue
record["displayName"] = account.displayName as CKRecordValue?
record["isActive"] = (account.isActive ? Int64(1) : Int64(0)) as CKRecordValue
record["dateAdded"] = account.dateAdded as CKRecordValue
record["updatedAt"] = account.updatedAt as CKRecordValue
@@ -961,6 +962,9 @@ final class CloudKitRecordMapper {
id: accountID,
instanceID: instanceID,
displayName: record["displayName"] as? String,
// Absent on records written before the field existed, and every
// such account is active — that was the only state there was.
isActive: ((record["isActive"] as? Int64) ?? 1) == 1,
dateAdded: dateAdded,
updatedAt: updatedAt
)

View File

@@ -19,6 +19,10 @@ enum DataContextUnavailability: Hashable, Sendable {
/// the user can turn the instance back on — but nothing may be served from
/// or written to a server they have disabled.
case instanceDisabled
/// The user set the account itself to inactive. Same shape as a disabled
/// instance: the selection is kept, the credential is untouched, and
/// nothing may be served from or written to an account they switched off.
case accountInactive
/// No credential on this device yet. On tvOS this never resolves on its own
/// — app Keychain items are not synced there — so the UI offers a local
/// sign-in rather than implying a wait.
@@ -108,12 +112,19 @@ final class DataContextValidator {
return .temporarilyUnavailable(.instanceDisabled)
}
// Like the disabled source above: not a credential state, and the
// selection is kept so activating the account restores it.
guard account.isActive else {
return .temporarilyUnavailable(.accountInactive)
}
switch accountsManager.status(for: id) {
case .signedIn: return .valid
case .checking: return .temporarilyUnavailable(.checking)
case .credentialMissing: return .temporarilyUnavailable(.credentialMissing)
case .credentialRejected: return .temporarilyUnavailable(.credentialRejected)
case .instanceDisabled: return .temporarilyUnavailable(.instanceDisabled)
case .inactive: return .temporarilyUnavailable(.accountInactive)
case .unreachable: return .temporarilyUnavailable(.unreachable)
}
}
@@ -163,9 +174,15 @@ final class DataContextValidator {
var contexts: [DataContext] = [.local]
contexts += accountsManager.selectableAccounts.map { DataContext.account($0.id) }
// An inactive account is the exception to "the selection is always
// listed": the user set it aside, and deactivating the selected
// account switches to local anyway — this covers the window where
// another device's deactivation has arrived ahead of its context
// switch.
let current = accountsManager.currentContext
if case let .account(id) = current,
accountsManager.account(id: id) != nil,
let account = accountsManager.account(id: id),
account.isActive,
!contexts.contains(current) {
contexts.append(current)
}
@@ -213,7 +230,7 @@ final class DataContextValidator {
return false
case let .temporarilyUnavailable(reason):
switch reason {
case .instanceDisabled, .credentialRejected, .credentialMissing:
case .instanceDisabled, .accountInactive, .credentialRejected, .credentialMissing:
return false
case .checking, .instancePending, .unreachable:
return true

View File

@@ -29,6 +29,7 @@ struct AccountStatusBadge: View {
case .credentialMissing: String(localized: "accounts.status.credentialMissing")
case .credentialRejected: String(localized: "accounts.status.credentialRejected")
case .instanceDisabled: String(localized: "accounts.status.instanceDisabled")
case .inactive: String(localized: "accounts.status.inactive")
case .unreachable: String(localized: "accounts.status.unreachable")
}
}
@@ -45,6 +46,7 @@ struct AccountStatusBadge: View {
case .credentialMissing: .secondary
case .credentialRejected: .red
case .instanceDisabled: .secondary
case .inactive: .secondary
case .unreachable: .orange
}
}

View File

@@ -294,7 +294,7 @@ enum DataContextSwitcherGlyph {
return true
case let .temporarilyUnavailable(reason):
switch reason {
case .instanceDisabled, .credentialRejected, .unreachable: return true
case .instanceDisabled, .accountInactive, .credentialRejected, .unreachable: return true
case .checking, .instancePending, .credentialMissing: return false
}
}

View File

@@ -40,6 +40,7 @@ struct DataContextUnavailableBanner: View {
case .checking: "clock"
case .instancePending: "clock"
case .instanceDisabled: "server.rack"
case .accountInactive: "person.crop.circle.badge.minus"
case .credentialMissing: "person.badge.key"
case .credentialRejected: "exclamationmark.triangle.fill"
case .unreachable: "wifi.exclamationmark"
@@ -50,6 +51,7 @@ struct DataContextUnavailableBanner: View {
switch reason {
case .checking, .instancePending: .secondary
case .instanceDisabled: .secondary
case .accountInactive: .secondary
case .credentialMissing: .secondary
case .credentialRejected: .red
case .unreachable: .orange
@@ -61,6 +63,7 @@ struct DataContextUnavailableBanner: View {
case .checking: String(localized: "subscriptions.banner.checking")
case .instancePending: String(localized: "subscriptions.banner.instancePending")
case .instanceDisabled: String(localized: "subscriptions.banner.instanceDisabled")
case .accountInactive: String(localized: "subscriptions.banner.accountInactive")
case .credentialMissing: String(localized: "subscriptions.banner.credentialMissing")
case .credentialRejected: String(localized: "subscriptions.banner.credentialRejected")
case .unreachable: String(localized: "subscriptions.banner.unreachable")

View File

@@ -359,6 +359,21 @@ struct AccountsListView: View {
Label(String(localized: "accounts.rename"), systemImage: "pencil")
}
// Not offered on an orphan: with no source there is nothing an active
// account could do that an inactive one cannot.
if instance != nil {
Button {
appEnvironment?.accountsManager.updateIsActive(!account.isActive, for: account.id)
} label: {
Label(
String(localized: account.isActive ? "accounts.deactivate" : "accounts.activate"),
systemImage: account.isActive
? "person.crop.circle.badge.minus"
: "person.crop.circle.badge.checkmark"
)
}
}
Button(role: .destructive) {
pendingRemoval = PendingAccountRemoval(
id: account.id,