import Foundation import Observation /// SSH keys, PGP keys, and email verification — the account rows the web /// has. Tokens are minted on the web or over SSH and get no UI here. @Observable @MainActor final class AccountViewModel { nonisolated struct SSHKey: Decodable, Sendable, Hashable, Identifiable { let fingerprint: String let algo: String let scope: String /// A name for the key (v1.21.0); omitted when none is set. let label: String? /// The token or key that added it, when one did rather than you. var createdBy: String? = nil /// When the key last signed in; absent when it never has. var lastUsedAt: Date? = nil /// When the key stops working; absent when it does not expire. var expiresAt: Date? = nil var id: String { fingerprint } enum CodingKeys: String, CodingKey { case fingerprint, algo, scope, label case createdBy = "created_by" case lastUsedAt = "last_used_at" case expiresAt = "expires_at" } func isExpired(at now: Date) -> Bool { expiresAt.map { $0 <= now } ?? false } /// What removal asks to be typed, as the web does: the first /// eight characters of the fingerprint after `SHA256:`. var confirmation: String { String(fingerprint.replacingOccurrences(of: "SHA256:", with: "").prefix(8)) } } nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable { let fingerprint: String /// The server stores the UID list JSON-encoded inside the field. let emails: String? var id: String { fingerprint } var emailList: [String] { guard let emails, let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else { return [] } return data } } /// One row of `email list` (v1.16.0). nonisolated struct EmailAddress: Decodable, Sendable, Hashable, Identifiable { let address: String let verified: Bool let verifiedBy: String? let primary: Bool var id: String { address } enum CodingKeys: String, CodingKey { case address, verified, primary case verifiedBy = "verified_by" } } nonisolated struct Loaded: Sendable, Hashable { let sshKeys: [SSHKey] let pgpKeys: [PGPKey] let orgs: [OrgMembership] } private(set) var state: LoadState = .loading private(set) var actionError: String? /// Set after `email add` succeeds, so the screen can say a code is /// on its way. private(set) var notice: String? private(set) var working = false /// Whether activity mail is on (v1.16.0); nil until read, or on a /// server without the setting. Inbox rows are filed either way. private(set) var mailOn: Bool? /// Whether the account watches every issue and merge request on the /// repositories it can write to (v1.19.0); nil until read, or on a /// server whose `show` has no `watch`. private(set) var watchOn: Bool? /// Set once `account delete` has mailed its confirmation link: the /// address it went to. Nothing changes until the link is opened. private(set) var deletionMailedTo: String? /// Whether replying to notification mail comments (gitbay v1.39.0). private(set) var replyOn: Bool? private var replyAvailable: Bool? /// The account's addresses; nil until read, or on a server without /// `email list`. private(set) var emails: [EmailAddress]? private let client: GitbayClient /// Where a deletion request is remembered across visits: the server /// has no read that says one is pending. private let defaults: UserDefaults init(client: GitbayClient, defaults: UserDefaults = .standard) { self.client = client self.defaults = defaults } func load() async { do { async let ssh = client.readList(["keys", "list"], of: SSHKey.self) async let pgp = client.readList(["pgp", "list"], of: PGPKey.self) async let orgs = client.readList(["org", "list"], of: OrgMembership.self) state = .loaded(Loaded( sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs )) } catch { state = .from(error) } } // MARK: - Notifications nonisolated private struct NotificationSettings: Decodable, Sendable { let mail: Bool let watch: Bool? let reply: Bool? let replyAvailable: Bool? enum CodingKeys: String, CodingKey { case mail, watch, reply case replyAvailable = "reply_available" } } func loadNotificationPreferences() async { let settings = try? await client.read( ["notifications", "settings", "show"], as: NotificationSettings.self) mailOn = settings?.mail watchOn = settings?.watch replyOn = settings?.reply replyAvailable = settings?.replyAvailable } /// The reply switch, offered where the instance reads replies to its /// mail (krz/gitbay#334). A server that does not say is offered it, /// and its refusal explains. var offersReply: Bool { replyOn != nil && replyAvailable != false } /// `notifications settings reply on|off`: issue and merge request mail /// carries a Reply-To, and a reply is posted as a comment. @discardableResult func setReply(_ on: Bool) async -> Bool { return await setPreference("reply", on) } /// `notifications settings mail on|off`, then read the value back. @discardableResult func setMail(_ on: Bool) async -> Bool { return await setPreference("mail", on) } /// `notifications settings watch on|off`, then read the value back. @discardableResult func setWatch(_ on: Bool) async -> Bool { return await setPreference("watch", on) } /// Whether the server took the change, so the switch can go back when /// it did not. private func setPreference(_ name: String, _ on: Bool) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(["notifications", "settings", name, on ? "on" : "off"]) await loadNotificationPreferences() return true } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } return false } // MARK: - Deleting the account nonisolated private struct DeletionRequested: Decodable, Sendable { let mailed: String } /// The link works for a day; after that the request is as good as gone. nonisolated static let deletionLinkLife: TimeInterval = 24 * 60 * 60 private func deletionKey(_ username: String) -> String { "gb.deletionRequest.\(client.instance.baseURL.absoluteString).\(username)" } /// A request made on an earlier visit whose link still works. func loadPendingDeletion(username: String, now: Date = .now) { guard let saved = defaults.dictionary(forKey: deletionKey(username)), let address = saved["mailed"] as? String, let at = saved["at"] as? Date, now.timeIntervalSince(at) < Self.deletionLinkLife else { defaults.removeObject(forKey: deletionKey(username)) return } deletionMailedTo = address } /// `account delete --confirm `: the server mails a link; /// opening it disables the account, and seven days later it is purged. func requestDeletion(username: String) async { working = true actionError = nil defer { working = false } do { let requested = try await client.run(["account", "delete", "--confirm", username], as: DeletionRequested.self) deletionMailedTo = requested?.mailed if let address = requested?.mailed { defaults.set(["mailed": address, "at": Date.now], forKey: deletionKey(username)) } } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } /// `account delete --cancel`: withdraws a request whose link has not /// been opened. func cancelDeletion(username: String) async { working = true actionError = nil defer { working = false } do { try await client.run(["account", "delete", "--cancel"]) deletionMailedTo = nil defaults.removeObject(forKey: deletionKey(username)) } catch GitbayError.notFound { // Nothing pending: the link was opened, or it lapsed. deletionMailedTo = nil defaults.removeObject(forKey: deletionKey(username)) } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } // MARK: - SSH keys /// `keys add [--scope full|git] [--label ]` — the /// authorized_keys line travels as raw stdin; a public key is not a /// secret. A blank label sends no flag. func addSSHKey(_ publicKey: String, scope: String, label: String = "", ttl: String? = nil) async { var argv = ["keys", "add", "--scope", scope] let label = label.trimmingCharacters(in: .whitespaces) if !label.isEmpty { argv.append(contentsOf: ["--label", label]) } if let ttl { argv.append(contentsOf: ["--ttl", ttl]) } await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines)) } /// `keys label []`: no text clears the label. func labelSSHKey(_ key: SSHKey, _ text: String) async { var argv = ["keys", "label", key.fingerprint] let text = text.trimmingCharacters(in: .whitespaces) if !text.isEmpty { argv.append(text) } await perform(argv) } func removeSSHKey(_ key: SSHKey) async { await perform(["keys", "remove", key.fingerprint]) } // MARK: - PGP keys func addPGPKey(_ armored: String) async { await perform(["pgp", "add"], stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines)) } func removePGPKey(_ key: PGPKey) async { await perform(["pgp", "remove", key.fingerprint]) } // MARK: - Email func addEmail(_ address: String) async { await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)]) if actionError == nil { notice = "A verification code is on its way to \(address)." await loadEmails() } } func verifyEmail(code: String) async { await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)]) if actionError == nil { notice = "Email verified." await loadEmails() } } func loadEmails() async { emails = try? await client.readList(["email", "list"], of: EmailAddress.self) } /// `email remove`: the primary and the last verified address are /// refused by the server, since identity resolves through them. func removeEmail(_ address: String) async { await performEmail(["email", "remove", address]) } /// `email primary`: moves the primary to a verified address. func setPrimaryEmail(_ address: String) async { await performEmail(["email", "primary", address]) } private func performEmail(_ argv: [String]) async { working = true actionError = nil notice = nil defer { working = false } do { try await client.run(argv) await loadEmails() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } private func perform(_ argv: [String], stdin: String? = nil) async { working = true actionError = nil notice = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() } catch let error as GitbayError { // Exit 2 is normally an app bug and stays generic, but on // this screen it validates pasted content ("not a valid // public key…", duplicates) — written for the person. if case .usage(let message) = error, !message.isEmpty { actionError = message } else { actionError = error.userFacingMessage } } catch { actionError = GitbayError.transport(error).userFacingMessage } } }