gitbay/Account/TokensViewModel.swift

v1.11.0
gitbay-ios/gitbay/Account/TokensViewModel.swift history · blame · raw

103 lines · 3455 bytes

  1import Foundation
  2import Observation
  3
  4/// One API token (`token list`): a name, what it may do, and when it was
  5/// used and stops working. The secret is shown once, when it is minted.
  6nonisolated struct APIToken: Decodable, Sendable, Hashable, Identifiable {
  7    let name: String
  8    let scope: String
  9    let createdAt: Date
 10    let expiresAt: Date?
 11    let lastUsedAt: Date?
 12    /// The token or key that minted it, when one did rather than you.
 13    let createdBy: String?
 14
 15    enum CodingKeys: String, CodingKey {
 16        case name, scope
 17        case createdAt = "created_at"
 18        case expiresAt = "expires_at"
 19        case lastUsedAt = "last_used_at"
 20        case createdBy = "created_by"
 21    }
 22
 23    var id: String { name }
 24
 25    func isExpired(at now: Date) -> Bool {
 26        expiresAt.map { $0 <= now } ?? false
 27    }
 28}
 29
 30/// `token list`, `token create` and `token revoke [--created]`.
 31@Observable
 32@MainActor
 33final class TokensViewModel {
 34
 35    /// What a revoked token had minted: tokens by name, keys by fingerprint.
 36    nonisolated struct Created: Decodable, Sendable, Hashable {
 37        let tokens: [String]?
 38        let keys: [String]?
 39        var isEmpty: Bool { (tokens ?? []).isEmpty && (keys ?? []).isEmpty }
 40    }
 41
 42    nonisolated private struct Minted: Decodable, Sendable {
 43        let token: String
 44    }
 45
 46    nonisolated private struct Revoked: Decodable, Sendable {
 47        let created: Created
 48    }
 49
 50    private(set) var state: LoadState<[APIToken]> = .loading
 51    private(set) var actionError: String?
 52    private(set) var working = false
 53
 54    private let client: GitbayClient
 55
 56    init(client: GitbayClient) {
 57        self.client = client
 58    }
 59
 60    func load() async {
 61        do {
 62            let tokens = try await client.readList(["token", "list"], of: APIToken.self)
 63            state = tokens.isEmpty ? .empty("No API tokens.") : .loaded(tokens)
 64        } catch {
 65            state = .from(error)
 66        }
 67    }
 68
 69    /// `token create --name <n> --scope <s> [--ttl <d>]`. Returns the
 70    /// secret, which the server shows this once and never again.
 71    func create(name: String, scope: String, ttl: String?) async -> String? {
 72        var argv = ["token", "create", "--name", name.trimmingCharacters(in: .whitespaces), "--scope", scope]
 73        if let ttl { argv += ["--ttl", ttl] }
 74        let minted: Minted? = await perform { try await self.client.run(argv, as: Minted.self) }
 75        return minted?.token
 76    }
 77
 78    /// `token revoke <name> [--created]`. Returns what the token had
 79    /// minted: revoked with it, or still in place.
 80    func revoke(_ token: APIToken, withCreated: Bool) async -> Created? {
 81        var argv = ["token", "revoke", token.name]
 82        if withCreated { argv.append("--created") }
 83        let revoked: Revoked? = await perform { try await self.client.run(argv, as: Revoked.self) }
 84        return revoked?.created
 85    }
 86
 87    private func perform<T: Sendable>(_ body: () async throws -> T?) async -> T? {
 88        working = true
 89        actionError = nil
 90        defer { working = false }
 91        do {
 92            let result = try await body()
 93            await load()
 94            return result
 95        } catch let error as GitbayError {
 96            // The name is typed, so a usage refusal of it is shown.
 97            actionError = error.messageForTypedInput
 98        } catch {
 99            actionError = GitbayError.transport(error).userFacingMessage
100        }
101        return nil
102    }
103}