gitbay/Account/AccountViewModel.swift
237 lines · 8216 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 var id: String { fingerprint }
17 /// What removal asks to be typed, as the web does: the first
18 /// eight characters of the fingerprint after `SHA256:`.
19 var confirmation: String {
20 String(fingerprint.replacingOccurrences(of: "SHA256:", with: "").prefix(8))
21 }
22 }
23
24 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
25 let fingerprint: String
26 /// The server stores the UID list JSON-encoded inside the field.
27 let emails: String?
28 var id: String { fingerprint }
29
30 var emailList: [String] {
31 guard let emails,
32 let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else {
33 return []
34 }
35 return data
36 }
37 }
38
39 /// One row of `email list` (v1.16.0).
40 nonisolated struct EmailAddress: Decodable, Sendable, Hashable, Identifiable {
41 let address: String
42 let verified: Bool
43 let verifiedBy: String?
44 let primary: Bool
45 var id: String { address }
46
47 enum CodingKeys: String, CodingKey {
48 case address, verified, primary
49 case verifiedBy = "verified_by"
50 }
51 }
52
53 nonisolated struct Loaded: Sendable, Hashable {
54 let sshKeys: [SSHKey]
55 let pgpKeys: [PGPKey]
56 let orgs: [OrgMembership]
57 }
58
59 private(set) var state: LoadState<Loaded> = .loading
60 private(set) var actionError: String?
61 /// Set after `email add` succeeds, so the screen can say a code is
62 /// on its way.
63 private(set) var notice: String?
64 private(set) var working = false
65 /// Whether activity mail is on (v1.16.0); nil until read, or on a
66 /// server without the setting. Inbox rows are filed either way.
67 private(set) var mailOn: Bool?
68 /// Whether the account watches every issue and merge request on the
69 /// repositories it can write to (v1.19.0); nil until read, or on a
70 /// server whose `show` has no `watch`.
71 private(set) var watchOn: Bool?
72 /// The account's addresses; nil until read, or on a server without
73 /// `email list`.
74 private(set) var emails: [EmailAddress]?
75
76 private let client: GitbayClient
77
78 init(client: GitbayClient) {
79 self.client = client
80 }
81
82 func load() async {
83 do {
84 async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
85 async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
86 async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
87 state = .loaded(Loaded(
88 sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
89 ))
90 } catch {
91 state = .from(error)
92 }
93 }
94
95 // MARK: - Notifications
96
97 nonisolated private struct NotificationSettings: Decodable, Sendable {
98 let mail: Bool
99 let watch: Bool?
100 }
101
102 func loadNotificationPreferences() async {
103 let settings = try? await client.read(
104 ["notifications", "settings", "show"], as: NotificationSettings.self)
105 mailOn = settings?.mail
106 watchOn = settings?.watch
107 }
108
109 /// `notifications settings mail on|off`, then read the value back.
110 func setMail(_ on: Bool) async {
111 await setPreference("mail", on)
112 }
113
114 /// `notifications settings watch on|off`, then read the value back.
115 func setWatch(_ on: Bool) async {
116 await setPreference("watch", on)
117 }
118
119 private func setPreference(_ name: String, _ on: Bool) async {
120 working = true
121 actionError = nil
122 defer { working = false }
123 do {
124 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
125 await loadNotificationPreferences()
126 } catch let error as GitbayError {
127 actionError = error.userFacingMessage
128 } catch {
129 actionError = GitbayError.transport(error).userFacingMessage
130 }
131 }
132
133 // MARK: - SSH keys
134
135 /// `keys add [--scope full|git] [--label <text>]` — the
136 /// authorized_keys line travels as raw stdin; a public key is not a
137 /// secret. A blank label sends no flag.
138 func addSSHKey(_ publicKey: String, scope: String, label: String = "") async {
139 var argv = ["keys", "add", "--scope", scope]
140 let label = label.trimmingCharacters(in: .whitespaces)
141 if !label.isEmpty { argv.append(contentsOf: ["--label", label]) }
142 await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
143 }
144
145 /// `keys label <fingerprint> [<text>]`: no text clears the label.
146 func labelSSHKey(_ key: SSHKey, _ text: String) async {
147 var argv = ["keys", "label", key.fingerprint]
148 let text = text.trimmingCharacters(in: .whitespaces)
149 if !text.isEmpty { argv.append(text) }
150 await perform(argv)
151 }
152
153 func removeSSHKey(_ key: SSHKey) async {
154 await perform(["keys", "remove", key.fingerprint])
155 }
156
157 // MARK: - PGP keys
158
159 func addPGPKey(_ armored: String) async {
160 await perform(["pgp", "add"],
161 stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
162 }
163
164 func removePGPKey(_ key: PGPKey) async {
165 await perform(["pgp", "remove", key.fingerprint])
166 }
167
168 // MARK: - Email
169
170 func addEmail(_ address: String) async {
171 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
172 if actionError == nil {
173 notice = "A verification code is on its way to \(address)."
174 await loadEmails()
175 }
176 }
177
178 func verifyEmail(code: String) async {
179 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
180 if actionError == nil {
181 notice = "Email verified."
182 await loadEmails()
183 }
184 }
185
186 func loadEmails() async {
187 emails = try? await client.readList(["email", "list"], of: EmailAddress.self)
188 }
189
190 /// `email remove`: the primary and the last verified address are
191 /// refused by the server, since identity resolves through them.
192 func removeEmail(_ address: String) async {
193 await performEmail(["email", "remove", address])
194 }
195
196 /// `email primary`: moves the primary to a verified address.
197 func setPrimaryEmail(_ address: String) async {
198 await performEmail(["email", "primary", address])
199 }
200
201 private func performEmail(_ argv: [String]) async {
202 working = true
203 actionError = nil
204 notice = nil
205 defer { working = false }
206 do {
207 try await client.run(argv)
208 await loadEmails()
209 } catch let error as GitbayError {
210 actionError = error.userFacingMessage
211 } catch {
212 actionError = GitbayError.transport(error).userFacingMessage
213 }
214 }
215
216 private func perform(_ argv: [String], stdin: String? = nil) async {
217 working = true
218 actionError = nil
219 notice = nil
220 defer { working = false }
221 do {
222 try await client.run(argv, stdin: stdin)
223 await load()
224 } catch let error as GitbayError {
225 // Exit 2 is normally an app bug and stays generic, but on
226 // this screen it validates pasted content ("not a valid
227 // public key…", duplicates) — written for the person.
228 if case .usage(let message) = error, !message.isEmpty {
229 actionError = message
230 } else {
231 actionError = error.userFacingMessage
232 }
233 } catch {
234 actionError = GitbayError.transport(error).userFacingMessage
235 }
236 }
237}