gitbay/Account/AccountViewModel.swift
203 lines · 6681 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 /// The account's addresses; nil until read, or on a server without
62 /// `email list`.
63 private(set) var emails: [EmailAddress]?
64
65 private let client: GitbayClient
66
67 init(client: GitbayClient) {
68 self.client = client
69 }
70
71 func load() async {
72 do {
73 async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
74 async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
75 async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
76 state = .loaded(Loaded(
77 sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
78 ))
79 } catch {
80 state = .from(error)
81 }
82 }
83
84 // MARK: - Notifications
85
86 nonisolated private struct MailSetting: Decodable, Sendable {
87 let mail: Bool
88 }
89
90 func loadMailPreference() async {
91 mailOn = (try? await client.read(
92 ["notifications", "settings", "show"], as: MailSetting.self))?.mail
93 }
94
95 /// `notifications settings mail on|off`, then read the value back.
96 func setMail(_ on: Bool) async {
97 working = true
98 actionError = nil
99 defer { working = false }
100 do {
101 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
102 await loadMailPreference()
103 } catch let error as GitbayError {
104 actionError = error.userFacingMessage
105 } catch {
106 actionError = GitbayError.transport(error).userFacingMessage
107 }
108 }
109
110 // MARK: - SSH keys
111
112 /// `keys add [--scope full|git]` — the authorized_keys line travels
113 /// as raw stdin; a public key is not a secret.
114 func addSSHKey(_ publicKey: String, scope: String) async {
115 await perform(["keys", "add", "--scope", scope],
116 stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
117 }
118
119 func removeSSHKey(_ key: SSHKey) async {
120 await perform(["keys", "remove", key.fingerprint])
121 }
122
123 // MARK: - PGP keys
124
125 func addPGPKey(_ armored: String) async {
126 await perform(["pgp", "add"],
127 stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
128 }
129
130 func removePGPKey(_ key: PGPKey) async {
131 await perform(["pgp", "remove", key.fingerprint])
132 }
133
134 // MARK: - Email
135
136 func addEmail(_ address: String) async {
137 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
138 if actionError == nil {
139 notice = "A verification code is on its way to \(address)."
140 await loadEmails()
141 }
142 }
143
144 func verifyEmail(code: String) async {
145 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
146 if actionError == nil {
147 notice = "Email verified."
148 await loadEmails()
149 }
150 }
151
152 func loadEmails() async {
153 emails = try? await client.readList(["email", "list"], of: EmailAddress.self)
154 }
155
156 /// `email remove`: the primary and the last verified address are
157 /// refused by the server, since identity resolves through them.
158 func removeEmail(_ address: String) async {
159 await performEmail(["email", "remove", address])
160 }
161
162 /// `email primary`: moves the primary to a verified address.
163 func setPrimaryEmail(_ address: String) async {
164 await performEmail(["email", "primary", address])
165 }
166
167 private func performEmail(_ argv: [String]) async {
168 working = true
169 actionError = nil
170 notice = nil
171 defer { working = false }
172 do {
173 try await client.run(argv)
174 await loadEmails()
175 } catch let error as GitbayError {
176 actionError = error.userFacingMessage
177 } catch {
178 actionError = GitbayError.transport(error).userFacingMessage
179 }
180 }
181
182 private func perform(_ argv: [String], stdin: String? = nil) async {
183 working = true
184 actionError = nil
185 notice = nil
186 defer { working = false }
187 do {
188 try await client.run(argv, stdin: stdin)
189 await load()
190 } catch let error as GitbayError {
191 // Exit 2 is normally an app bug and stays generic, but on
192 // this screen it validates pasted content ("not a valid
193 // public key…", duplicates) — written for the person.
194 if case .usage(let message) = error, !message.isEmpty {
195 actionError = message
196 } else {
197 actionError = error.userFacingMessage
198 }
199 } catch {
200 actionError = GitbayError.transport(error).userFacingMessage
201 }
202 }
203}