import Foundation import Observation /// `admin user show `: an account as an instance admin sees it. nonisolated struct AdminAccountDetail: Decodable, Sendable, Hashable { nonisolated struct Key: Decodable, Sendable, Hashable, Identifiable { let fingerprint: String let algo: String let scope: String let label: String let createdAt: Date let lastUsedAt: Date? var id: String { fingerprint } enum CodingKeys: String, CodingKey { case fingerprint, algo, scope, label case createdAt = "created_at" case lastUsedAt = "last_used_at" } } nonisolated struct Email: Decodable, Sendable, Hashable, Identifiable { let address: String let verified: Bool let primary: Bool var id: String { address } } nonisolated struct Membership: Decodable, Sendable, Hashable, Identifiable { let org: String let role: String var id: String { org } } nonisolated struct Token: Decodable, Sendable, Hashable, Identifiable { let name: String let scope: String let expiresAt: Date? let lastUsedAt: Date? var id: String { name } enum CodingKeys: String, CodingKey { case name, scope case expiresAt = "expires_at" case lastUsedAt = "last_used_at" } } let username: String let state: String let admin: Bool let createdAt: Date let keys: [Key] let emails: [Email] let orgs: [Membership] let repos: Int64 let apiTokens: [Token] let webSessions: Int64 /// When a self-deletion the account confirmed will purge it. let deleteAfter: String? enum CodingKeys: String, CodingKey { case username, state, admin, keys, emails, orgs, repos case createdAt = "created_at" case apiTokens = "api_tokens" case webSessions = "web_sessions" case deleteAfter = "delete_after" } init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) username = try c.decode(String.self, forKey: .username) state = try c.decode(String.self, forKey: .state) admin = try c.decodeIfPresent(Bool.self, forKey: .admin) ?? false createdAt = try c.decode(Date.self, forKey: .createdAt) keys = try c.decodeIfPresent([Key].self, forKey: .keys) ?? [] emails = try c.decodeIfPresent([Email].self, forKey: .emails) ?? [] orgs = try c.decodeIfPresent([Membership].self, forKey: .orgs) ?? [] repos = try c.decodeIfPresent(Int64.self, forKey: .repos) ?? 0 apiTokens = try c.decodeIfPresent([Token].self, forKey: .apiTokens) ?? [] webSessions = try c.decodeIfPresent(Int64.self, forKey: .webSessions) ?? 0 deleteAfter = try c.decodeIfPresent(String.self, forKey: .deleteAfter) } } /// `admin user limits` and `admin org limits`: the effective caps, 0 for /// unlimited, and what the owner holds against them. Organizations have /// no organization cap. nonisolated struct OwnerLimits: Decodable, Sendable, Hashable { let repos: Int64 let bytes: Int64 let orgs: Int64? let reposOwned: Int64 let bytesOwned: Int64 let orgsCreated: Int64? /// Some cap is set on this owner rather than taken from the instance. let override: Bool enum CodingKeys: String, CodingKey { case repos, bytes, orgs, override case reposOwned = "repos_owned" case bytesOwned = "bytes_owned" case orgsCreated = "orgs_created" } /// The flags for a limits edit. Each field is left out when empty, /// sent as `default` when it says so, and otherwise as a number; /// storage is typed in megabytes and sent in bytes. static func flags(repos: String, megabytes: String, orgs: String?) -> [String]? { var flags: [String] = [] func add(_ flag: String, _ text: String, scale: Int64 = 1) -> Bool { let text = text.trimmingCharacters(in: .whitespaces).lowercased() guard !text.isEmpty else { return true } if text == "default" { flags += [flag, "default"] return true } guard let n = Int64(text), n >= 0 else { return false } let (scaled, overflow) = n.multipliedReportingOverflow(by: scale) guard !overflow else { return false } flags += [flag, String(scaled)] return true } guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil } if let orgs, !add("--orgs", orgs) { return nil } return flags } } /// One account for an instance admin: `admin user show`, its limits, /// editing them, and deleting the account. @Observable @MainActor final class AdminAccountViewModel { nonisolated struct Loaded: Sendable, Hashable { let detail: AdminAccountDetail let limits: OwnerLimits? /// Why the caps could not be read, when they could not. let limitsError: String? } private(set) var state: LoadState = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let username: String init(client: GitbayClient, username: String) { self.client = client self.username = username } func load() async { do { async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self) // Not a read-only command (it also sets caps), so it goes as a // command even to read; with no flags it changes nothing. A // failed read says why under the account, not as its absence. async let limits = readLimits() let (caps, why) = await limits state = .loaded(Loaded(detail: try await detail, limits: caps, limitsError: why)) } catch { state = .from(error) } } private func readLimits() async -> (OwnerLimits?, String?) { do { return (try await client.run(["admin", "user", "limits", username], as: OwnerLimits.self), nil) } catch let error as GitbayError { return (nil, error.userFacingMessage) } catch { return (nil, GitbayError.transport(error).userFacingMessage) } } /// Returns whether the caps changed; a field that is not a number or /// `default` is refused here, before the server is asked. func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool { guard let flags = OwnerLimits.flags(repos: repos, megabytes: megabytes, orgs: orgs) else { actionError = "A cap is a whole number, 0 for unlimited, or default for the instance's." return false } guard !flags.isEmpty else { return true } return await perform(["admin", "user", "limits", username] + flags) } /// `admin user delete --yes`, for an account that anchors /// nothing; the server's refusal names what it still holds. func delete() async -> Bool { if await perform(["admin", "user", "delete", username, "--yes"], reload: false) { return true } // A failure is retried once; a delete that went through and then // failed answers the retry with not found, and the account is gone. return notFoundOnRetry } private var notFoundOnRetry = false private func perform(_ argv: [String], reload: Bool = true) async -> Bool { working = true actionError = nil defer { working = false } notFoundOnRetry = false do { try await client.run(argv) } catch GitbayError.notFound(let message) where argv.contains("delete") { notFoundOnRetry = true actionError = message return false } catch let error as GitbayError { actionError = error.messageForTypedInput return false } catch { actionError = GitbayError.transport(error).userFacingMessage return false } if reload { await load() } return true } } extension AdminUsersViewModel { nonisolated private struct Invited: Decodable, Sendable { let email: String let mailed: Bool? let code: String? } /// `admin user create [--admin] [--email [--verified]] /// [--key -]`, the public key on stdin. Returns whether it was made. func create(username: String, email: String, verified: Bool, admin: Bool, key: String) async -> Bool { var argv = ["admin", "user", "create", username.trimmingCharacters(in: .whitespaces)] if admin { argv.append("--admin") } let email = email.trimmingCharacters(in: .whitespaces) if !email.isEmpty { argv += ["--email", email] if verified { argv.append("--verified") } } let key = key.trimmingCharacters(in: .whitespacesAndNewlines) if !key.isEmpty { argv += ["--key", "-"] } return await write(argv, stdin: key.isEmpty ? nil : key) } /// `admin invite --email
`. Says where the code went: mailed, /// or, where the instance cannot mail, the code itself to pass on. func invite(email: String) async -> String? { let email = email.trimmingCharacters(in: .whitespaces) do { let invited = try await client.run(["admin", "invite", "--email", email], as: Invited.self) if let code = invited?.code { return "Invited \(email). Mail is off here, so pass on the code: \(code)" } return "Invited \(email); the code is in their mail." } catch let error as GitbayError { inviteError = error.messageForTypedInput } catch { inviteError = GitbayError.transport(error).userFacingMessage } return nil } }