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