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