gitbay/Account/AccountViewModel.swift
358 lines · 13198 bytes
27 symbols in this file
AccountViewModelCodingKeysisExpiredCodingKeysloadCodingKeysloadNotificationPreferencessetReplysetMailsetWatchsetPreferencedeletionKeyloadPendingDeletionrequestDeletioncancelDeletionaddSSHKeylabelSSHKeyremoveSSHKeyaddPGPKeyremovePGPKeyaddEmailverifyEmailloadEmailsremoveEmailsetPrimaryEmailperformEmailperform
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}