gitbay/Admin/AdminAccount.swift
260 lines · 10048 bytes
1import Foundation
2import Observation
3
4/// `admin user show <username>`: an account as an instance admin sees it.
5nonisolated struct AdminAccountDetail: Decodable, Sendable, Hashable {
6 nonisolated struct Key: Decodable, Sendable, Hashable, Identifiable {
7 let fingerprint: String
8 let algo: String
9 let scope: String
10 let label: String
11 let createdAt: Date
12 let lastUsedAt: Date?
13 var id: String { fingerprint }
14 enum CodingKeys: String, CodingKey {
15 case fingerprint, algo, scope, label
16 case createdAt = "created_at"
17 case lastUsedAt = "last_used_at"
18 }
19 }
20
21 nonisolated struct Email: Decodable, Sendable, Hashable, Identifiable {
22 let address: String
23 let verified: Bool
24 let primary: Bool
25 var id: String { address }
26 }
27
28 nonisolated struct Membership: Decodable, Sendable, Hashable, Identifiable {
29 let org: String
30 let role: String
31 var id: String { org }
32 }
33
34 nonisolated struct Token: Decodable, Sendable, Hashable, Identifiable {
35 let name: String
36 let scope: String
37 let expiresAt: Date?
38 let lastUsedAt: Date?
39 var id: String { name }
40 enum CodingKeys: String, CodingKey {
41 case name, scope
42 case expiresAt = "expires_at"
43 case lastUsedAt = "last_used_at"
44 }
45 }
46
47 let username: String
48 let state: String
49 let admin: Bool
50 let createdAt: Date
51 let keys: [Key]
52 let emails: [Email]
53 let orgs: [Membership]
54 let repos: Int64
55 let apiTokens: [Token]
56 let webSessions: Int64
57 /// When a self-deletion the account confirmed will purge it.
58 let deleteAfter: String?
59
60 enum CodingKeys: String, CodingKey {
61 case username, state, admin, keys, emails, orgs, repos
62 case createdAt = "created_at"
63 case apiTokens = "api_tokens"
64 case webSessions = "web_sessions"
65 case deleteAfter = "delete_after"
66 }
67
68 init(from decoder: any Decoder) throws {
69 let c = try decoder.container(keyedBy: CodingKeys.self)
70 username = try c.decode(String.self, forKey: .username)
71 state = try c.decode(String.self, forKey: .state)
72 admin = try c.decodeIfPresent(Bool.self, forKey: .admin) ?? false
73 createdAt = try c.decode(Date.self, forKey: .createdAt)
74 keys = try c.decodeIfPresent([Key].self, forKey: .keys) ?? []
75 emails = try c.decodeIfPresent([Email].self, forKey: .emails) ?? []
76 orgs = try c.decodeIfPresent([Membership].self, forKey: .orgs) ?? []
77 repos = try c.decodeIfPresent(Int64.self, forKey: .repos) ?? 0
78 apiTokens = try c.decodeIfPresent([Token].self, forKey: .apiTokens) ?? []
79 webSessions = try c.decodeIfPresent(Int64.self, forKey: .webSessions) ?? 0
80 deleteAfter = try c.decodeIfPresent(String.self, forKey: .deleteAfter)
81 }
82}
83
84/// `admin user limits` and `admin org limits`: the effective caps, 0 for
85/// unlimited, and what the owner holds against them. Organizations have
86/// no organization cap.
87nonisolated struct OwnerLimits: Decodable, Sendable, Hashable {
88 let repos: Int64
89 let bytes: Int64
90 let orgs: Int64?
91 let reposOwned: Int64
92 let bytesOwned: Int64
93 let orgsCreated: Int64?
94 /// Some cap is set on this owner rather than taken from the instance.
95 let override: Bool
96
97 enum CodingKeys: String, CodingKey {
98 case repos, bytes, orgs, override
99 case reposOwned = "repos_owned"
100 case bytesOwned = "bytes_owned"
101 case orgsCreated = "orgs_created"
102 }
103
104 /// The flags for a limits edit. Each field is left out when empty,
105 /// sent as `default` when it says so, and otherwise as a number;
106 /// storage is typed in megabytes and sent in bytes.
107 static func flags(repos: String, megabytes: String, orgs: String?) -> [String]? {
108 var flags: [String] = []
109 func add(_ flag: String, _ text: String, scale: Int64 = 1) -> Bool {
110 let text = text.trimmingCharacters(in: .whitespaces).lowercased()
111 guard !text.isEmpty else { return true }
112 if text == "default" {
113 flags += [flag, "default"]
114 return true
115 }
116 guard let n = Int64(text), n >= 0 else { return false }
117 let (scaled, overflow) = n.multipliedReportingOverflow(by: scale)
118 guard !overflow else { return false }
119 flags += [flag, String(scaled)]
120 return true
121 }
122 guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil }
123 if let orgs, !add("--orgs", orgs) { return nil }
124 return flags
125 }
126}
127
128/// One account for an instance admin: `admin user show`, its limits,
129/// editing them, and deleting the account.
130@Observable
131@MainActor
132final class AdminAccountViewModel {
133
134 nonisolated struct Loaded: Sendable, Hashable {
135 let detail: AdminAccountDetail
136 let limits: OwnerLimits?
137 /// Why the caps could not be read, when they could not.
138 let limitsError: String?
139 }
140
141 private(set) var state: LoadState<Loaded> = .loading
142 private(set) var actionError: String?
143 private(set) var working = false
144
145 private let client: GitbayClient
146 let username: String
147
148 init(client: GitbayClient, username: String) {
149 self.client = client
150 self.username = username
151 }
152
153 func load() async {
154 do {
155 async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self)
156 // Not a read-only command (it also sets caps), so it goes as a
157 // command even to read; with no flags it changes nothing. A
158 // failed read says why under the account, not as its absence.
159 async let limits = readLimits()
160 let (caps, why) = await limits
161 state = .loaded(Loaded(detail: try await detail, limits: caps, limitsError: why))
162 } catch {
163 state = .from(error)
164 }
165 }
166
167 private func readLimits() async -> (OwnerLimits?, String?) {
168 do {
169 return (try await client.run(["admin", "user", "limits", username], as: OwnerLimits.self), nil)
170 } catch let error as GitbayError {
171 return (nil, error.userFacingMessage)
172 } catch {
173 return (nil, GitbayError.transport(error).userFacingMessage)
174 }
175 }
176
177 /// Returns whether the caps changed; a field that is not a number or
178 /// `default` is refused here, before the server is asked.
179 func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool {
180 guard let flags = OwnerLimits.flags(repos: repos, megabytes: megabytes, orgs: orgs) else {
181 actionError = "A cap is a whole number, 0 for unlimited, or default for the instance's."
182 return false
183 }
184 guard !flags.isEmpty else { return true }
185 return await perform(["admin", "user", "limits", username] + flags)
186 }
187
188 /// `admin user delete <username> --yes`, for an account that anchors
189 /// nothing; the server's refusal names what it still holds.
190 func delete() async -> Bool {
191 if await perform(["admin", "user", "delete", username, "--yes"], reload: false) { return true }
192 // A failure is retried once; a delete that went through and then
193 // failed answers the retry with not found, and the account is gone.
194 return notFoundOnRetry
195 }
196
197 private var notFoundOnRetry = false
198
199 private func perform(_ argv: [String], reload: Bool = true) async -> Bool {
200 working = true
201 actionError = nil
202 defer { working = false }
203 notFoundOnRetry = false
204 do {
205 try await client.run(argv)
206 } catch GitbayError.notFound(let message) where argv.contains("delete") {
207 notFoundOnRetry = true
208 actionError = message
209 return false
210 } catch let error as GitbayError {
211 actionError = error.messageForTypedInput
212 return false
213 } catch {
214 actionError = GitbayError.transport(error).userFacingMessage
215 return false
216 }
217 if reload { await load() }
218 return true
219 }
220}
221
222extension AdminUsersViewModel {
223
224 nonisolated private struct Invited: Decodable, Sendable {
225 let email: String
226 let mailed: Bool?
227 let code: String?
228 }
229
230 /// `admin user create <username> [--admin] [--email <a> [--verified]]
231 /// [--key -]`, the public key on stdin. Returns whether it was made.
232 func create(username: String, email: String, verified: Bool, admin: Bool, key: String) async -> Bool {
233 var argv = ["admin", "user", "create", username.trimmingCharacters(in: .whitespaces)]
234 if admin { argv.append("--admin") }
235 let email = email.trimmingCharacters(in: .whitespaces)
236 if !email.isEmpty {
237 argv += ["--email", email]
238 if verified { argv.append("--verified") }
239 }
240 let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
241 if !key.isEmpty { argv += ["--key", "-"] }
242 return await write(argv, stdin: key.isEmpty ? nil : key)
243 }
244
245 /// `admin invite --email <address>`. Says where the code went: mailed,
246 /// or, where the instance cannot mail, the code itself to pass on.
247 func invite(email: String) async -> String? {
248 let email = email.trimmingCharacters(in: .whitespaces)
249 do {
250 let invited = try await client.run(["admin", "invite", "--email", email], as: Invited.self)
251 if let code = invited?.code { return "Invited \(email). Mail is off here, so pass on the code: \(code)" }
252 return "Invited \(email); the code is in their mail."
253 } catch let error as GitbayError {
254 inviteError = error.messageForTypedInput
255 } catch {
256 inviteError = GitbayError.transport(error).userFacingMessage
257 }
258 return nil
259 }
260}