gitbay/Account/AccountViewModel.swift

v1.13.1
gitbay-ios/gitbay/Account/AccountViewModel.swift history · blame · raw

358 lines · 13198 bytes

  1import Foundation
  2import Observation
  3
  4/// SSH keys, PGP keys, and email verification — the account rows the web
  5/// has. Tokens are minted on the web or over SSH and get no UI here.
  6@Observable
  7@MainActor
  8final class AccountViewModel {
  9
 10    nonisolated struct SSHKey: Decodable, Sendable, Hashable, Identifiable {
 11        let fingerprint: String
 12        let algo: String
 13        let scope: String
 14        /// A name for the key (v1.21.0); omitted when none is set.
 15        let label: String?
 16        /// The token or key that added it, when one did rather than you.
 17        var createdBy: String? = nil
 18        /// When the key last signed in; absent when it never has.
 19        var lastUsedAt: Date? = nil
 20        /// When the key stops working; absent when it does not expire.
 21        var expiresAt: Date? = nil
 22        var id: String { fingerprint }
 23
 24        enum CodingKeys: String, CodingKey {
 25            case fingerprint, algo, scope, label
 26            case createdBy = "created_by"
 27            case lastUsedAt = "last_used_at"
 28            case expiresAt = "expires_at"
 29        }
 30
 31        func isExpired(at now: Date) -> Bool {
 32            expiresAt.map { $0 <= now } ?? false
 33        }
 34        /// What removal asks to be typed, as the web does: the first
 35        /// eight characters of the fingerprint after `SHA256:`.
 36        var confirmation: String {
 37            String(fingerprint.replacingOccurrences(of: "SHA256:", with: "").prefix(8))
 38        }
 39    }
 40
 41    nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
 42        let fingerprint: String
 43        /// The server stores the UID list JSON-encoded inside the field.
 44        let emails: String?
 45        var id: String { fingerprint }
 46
 47        var emailList: [String] {
 48            guard let emails,
 49                  let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else {
 50                return []
 51            }
 52            return data
 53        }
 54    }
 55
 56    /// One row of `email list` (v1.16.0).
 57    nonisolated struct EmailAddress: Decodable, Sendable, Hashable, Identifiable {
 58        let address: String
 59        let verified: Bool
 60        let verifiedBy: String?
 61        let primary: Bool
 62        var id: String { address }
 63
 64        enum CodingKeys: String, CodingKey {
 65            case address, verified, primary
 66            case verifiedBy = "verified_by"
 67        }
 68    }
 69
 70    nonisolated struct Loaded: Sendable, Hashable {
 71        let sshKeys: [SSHKey]
 72        let pgpKeys: [PGPKey]
 73        let orgs: [OrgMembership]
 74    }
 75
 76    private(set) var state: LoadState<Loaded> = .loading
 77    private(set) var actionError: String?
 78    /// Set after `email add` succeeds, so the screen can say a code is
 79    /// on its way.
 80    private(set) var notice: String?
 81    private(set) var working = false
 82    /// Whether activity mail is on (v1.16.0); nil until read, or on a
 83    /// server without the setting. Inbox rows are filed either way.
 84    private(set) var mailOn: Bool?
 85    /// Whether the account watches every issue and merge request on the
 86    /// repositories it can write to (v1.19.0); nil until read, or on a
 87    /// server whose `show` has no `watch`.
 88    private(set) var watchOn: Bool?
 89    /// Set once `account delete` has mailed its confirmation link: the
 90    /// address it went to. Nothing changes until the link is opened.
 91    private(set) var deletionMailedTo: String?
 92    /// Whether replying to notification mail comments (gitbay v1.39.0).
 93    private(set) var replyOn: Bool?
 94    private var replyAvailable: Bool?
 95    /// The account's addresses; nil until read, or on a server without
 96    /// `email list`.
 97    private(set) var emails: [EmailAddress]?
 98
 99    private let client: GitbayClient
100
101    /// Where a deletion request is remembered across visits: the server
102    /// has no read that says one is pending.
103    private let defaults: UserDefaults
104
105    init(client: GitbayClient, defaults: UserDefaults = .standard) {
106        self.client = client
107        self.defaults = defaults
108    }
109
110    func load() async {
111        do {
112            async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
113            async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
114            async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
115            state = .loaded(Loaded(
116                sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
117            ))
118        } catch {
119            state = .from(error)
120        }
121    }
122
123    // MARK: - Notifications
124
125    nonisolated private struct NotificationSettings: Decodable, Sendable {
126        let mail: Bool
127        let watch: Bool?
128        let reply: Bool?
129        let replyAvailable: Bool?
130
131        enum CodingKeys: String, CodingKey {
132            case mail, watch, reply
133            case replyAvailable = "reply_available"
134        }
135    }
136
137    func loadNotificationPreferences() async {
138        let settings = try? await client.read(
139            ["notifications", "settings", "show"], as: NotificationSettings.self)
140        mailOn = settings?.mail
141        watchOn = settings?.watch
142        replyOn = settings?.reply
143        replyAvailable = settings?.replyAvailable
144    }
145
146    /// The reply switch, offered where the instance reads replies to its
147    /// mail (krz/gitbay#334). A server that does not say is offered it,
148    /// and its refusal explains.
149    var offersReply: Bool { replyOn != nil && replyAvailable != false }
150
151    /// `notifications settings reply on|off`: issue and merge request mail
152    /// carries a Reply-To, and a reply is posted as a comment.
153    @discardableResult
154    func setReply(_ on: Bool) async -> Bool {
155        return await setPreference("reply", on)
156    }
157
158    /// `notifications settings mail on|off`, then read the value back.
159    @discardableResult
160    func setMail(_ on: Bool) async -> Bool {
161        return await setPreference("mail", on)
162    }
163
164    /// `notifications settings watch on|off`, then read the value back.
165    @discardableResult
166    func setWatch(_ on: Bool) async -> Bool {
167        return await setPreference("watch", on)
168    }
169
170    /// Whether the server took the change, so the switch can go back when
171    /// it did not.
172    private func setPreference(_ name: String, _ on: Bool) async -> Bool {
173        working = true
174        actionError = nil
175        defer { working = false }
176        do {
177            try await client.run(["notifications", "settings", name, on ? "on" : "off"])
178            await loadNotificationPreferences()
179            return true
180        } catch let error as GitbayError {
181            actionError = error.userFacingMessage
182        } catch {
183            actionError = GitbayError.transport(error).userFacingMessage
184        }
185        return false
186    }
187
188    // MARK: - Deleting the account
189
190    nonisolated private struct DeletionRequested: Decodable, Sendable {
191        let mailed: String
192    }
193
194    /// The link works for a day; after that the request is as good as gone.
195    nonisolated static let deletionLinkLife: TimeInterval = 24 * 60 * 60
196
197    private func deletionKey(_ username: String) -> String {
198        "gb.deletionRequest.\(client.instance.baseURL.absoluteString).\(username)"
199    }
200
201    /// A request made on an earlier visit whose link still works.
202    func loadPendingDeletion(username: String, now: Date = .now) {
203        guard let saved = defaults.dictionary(forKey: deletionKey(username)),
204              let address = saved["mailed"] as? String,
205              let at = saved["at"] as? Date, now.timeIntervalSince(at) < Self.deletionLinkLife else {
206            defaults.removeObject(forKey: deletionKey(username))
207            return
208        }
209        deletionMailedTo = address
210    }
211
212    /// `account delete --confirm <username>`: the server mails a link;
213    /// opening it disables the account, and seven days later it is purged.
214    func requestDeletion(username: String) async {
215        working = true
216        actionError = nil
217        defer { working = false }
218        do {
219            let requested = try await client.run(["account", "delete", "--confirm", username],
220                                                 as: DeletionRequested.self)
221            deletionMailedTo = requested?.mailed
222            if let address = requested?.mailed {
223                defaults.set(["mailed": address, "at": Date.now], forKey: deletionKey(username))
224            }
225        } catch let error as GitbayError {
226            actionError = error.userFacingMessage
227        } catch {
228            actionError = GitbayError.transport(error).userFacingMessage
229        }
230    }
231
232    /// `account delete --cancel`: withdraws a request whose link has not
233    /// been opened.
234    func cancelDeletion(username: String) async {
235        working = true
236        actionError = nil
237        defer { working = false }
238        do {
239            try await client.run(["account", "delete", "--cancel"])
240            deletionMailedTo = nil
241            defaults.removeObject(forKey: deletionKey(username))
242        } catch GitbayError.notFound {
243            // Nothing pending: the link was opened, or it lapsed.
244            deletionMailedTo = nil
245            defaults.removeObject(forKey: deletionKey(username))
246        } catch let error as GitbayError {
247            actionError = error.userFacingMessage
248        } catch {
249            actionError = GitbayError.transport(error).userFacingMessage
250        }
251    }
252
253    // MARK: - SSH keys
254
255    /// `keys add [--scope full|git] [--label <text>]` — the
256    /// authorized_keys line travels as raw stdin; a public key is not a
257    /// secret. A blank label sends no flag.
258    func addSSHKey(_ publicKey: String, scope: String, label: String = "", ttl: String? = nil) async {
259        var argv = ["keys", "add", "--scope", scope]
260        let label = label.trimmingCharacters(in: .whitespaces)
261        if !label.isEmpty { argv.append(contentsOf: ["--label", label]) }
262        if let ttl { argv.append(contentsOf: ["--ttl", ttl]) }
263        await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
264    }
265
266    /// `keys label <fingerprint> [<text>]`: no text clears the label.
267    func labelSSHKey(_ key: SSHKey, _ text: String) async {
268        var argv = ["keys", "label", key.fingerprint]
269        let text = text.trimmingCharacters(in: .whitespaces)
270        if !text.isEmpty { argv.append(text) }
271        await perform(argv)
272    }
273
274    func removeSSHKey(_ key: SSHKey) async {
275        await perform(["keys", "remove", key.fingerprint])
276    }
277
278    // MARK: - PGP keys
279
280    func addPGPKey(_ armored: String) async {
281        await perform(["pgp", "add"],
282                      stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
283    }
284
285    func removePGPKey(_ key: PGPKey) async {
286        await perform(["pgp", "remove", key.fingerprint])
287    }
288
289    // MARK: - Email
290
291    func addEmail(_ address: String) async {
292        await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
293        if actionError == nil {
294            notice = "A verification code is on its way to \(address)."
295            await loadEmails()
296        }
297    }
298
299    func verifyEmail(code: String) async {
300        await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
301        if actionError == nil {
302            notice = "Email verified."
303            await loadEmails()
304        }
305    }
306
307    func loadEmails() async {
308        emails = try? await client.readList(["email", "list"], of: EmailAddress.self)
309    }
310
311    /// `email remove`: the primary and the last verified address are
312    /// refused by the server, since identity resolves through them.
313    func removeEmail(_ address: String) async {
314        await performEmail(["email", "remove", address])
315    }
316
317    /// `email primary`: moves the primary to a verified address.
318    func setPrimaryEmail(_ address: String) async {
319        await performEmail(["email", "primary", address])
320    }
321
322    private func performEmail(_ argv: [String]) async {
323        working = true
324        actionError = nil
325        notice = nil
326        defer { working = false }
327        do {
328            try await client.run(argv)
329            await loadEmails()
330        } catch let error as GitbayError {
331            actionError = error.userFacingMessage
332        } catch {
333            actionError = GitbayError.transport(error).userFacingMessage
334        }
335    }
336
337    private func perform(_ argv: [String], stdin: String? = nil) async {
338        working = true
339        actionError = nil
340        notice = nil
341        defer { working = false }
342        do {
343            try await client.run(argv, stdin: stdin)
344            await load()
345        } catch let error as GitbayError {
346            // Exit 2 is normally an app bug and stays generic, but on
347            // this screen it validates pasted content ("not a valid
348            // public key…", duplicates) — written for the person.
349            if case .usage(let message) = error, !message.isEmpty {
350                actionError = message
351            } else {
352                actionError = error.userFacingMessage
353            }
354        } catch {
355            actionError = GitbayError.transport(error).userFacingMessage
356        }
357    }
358}