gitbay/Admin/AdminAccount.swift

main
gitbay-ios/gitbay/Admin/AdminAccount.swift history · blame · raw

260 lines · 10048 bytes

14 symbols in this file
  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}