gitbay/Account/AccountViewModel.swift
125 lines · 4050 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 nonisolated struct Loaded: Sendable, Hashable {
33 let sshKeys: [SSHKey]
34 let pgpKeys: [PGPKey]
35 let orgs: [OrgMembership]
36 }
37
38 private(set) var state: LoadState<Loaded> = .loading
39 private(set) var actionError: String?
40 /// Set after `email add` succeeds, so the screen can say a code is
41 /// on its way.
42 private(set) var notice: String?
43 private(set) var working = false
44
45 private let client: GitbayClient
46
47 init(client: GitbayClient) {
48 self.client = client
49 }
50
51 func load() async {
52 do {
53 async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
54 async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
55 async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
56 state = .loaded(Loaded(
57 sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
58 ))
59 } catch {
60 state = .from(error)
61 }
62 }
63
64 // MARK: - SSH keys
65
66 /// `keys add [--scope full|git]` — the authorized_keys line travels
67 /// as raw stdin; a public key is not a secret.
68 func addSSHKey(_ publicKey: String, scope: String) async {
69 await perform(["keys", "add", "--scope", scope],
70 stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
71 }
72
73 func removeSSHKey(_ key: SSHKey) async {
74 await perform(["keys", "remove", key.fingerprint])
75 }
76
77 // MARK: - PGP keys
78
79 func addPGPKey(_ armored: String) async {
80 await perform(["pgp", "add"],
81 stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
82 }
83
84 func removePGPKey(_ key: PGPKey) async {
85 await perform(["pgp", "remove", key.fingerprint])
86 }
87
88 // MARK: - Email
89
90 func addEmail(_ address: String) async {
91 await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
92 if actionError == nil {
93 notice = "A verification code is on its way to \(address)."
94 }
95 }
96
97 func verifyEmail(code: String) async {
98 await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
99 if actionError == nil {
100 notice = "Email verified."
101 }
102 }
103
104 private func perform(_ argv: [String], stdin: String? = nil) async {
105 working = true
106 actionError = nil
107 notice = nil
108 defer { working = false }
109 do {
110 try await client.run(argv, stdin: stdin)
111 await load()
112 } catch let error as GitbayError {
113 // Exit 2 is normally an app bug and stays generic, but on
114 // this screen it validates pasted content ("not a valid
115 // public key…", duplicates) — written for the person.
116 if case .usage(let message) = error, !message.isEmpty {
117 actionError = message
118 } else {
119 actionError = error.userFacingMessage
120 }
121 } catch {
122 actionError = GitbayError.transport(error).userFacingMessage
123 }
124 }
125}