gitbay/Account/TokensViewModel.swift
103 lines · 3455 bytes
7 symbols in this file
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}