API tokens: mint, list, revoke, and revoke with what one created !170

merged merged by cmc on 2026-10-14 08:08 UTC · krz/gitbay-ios:api-tokens into main

7 files changed, +444 −2

Layout: unified · split

gitbay/Account/TokensViewModel.swift added +103
@@ -0,0 +1,103 @@
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}
gitbay/ContentView.swift +2
@@ -337,6 +337,8 @@ private struct RouteDestinations: ViewModifier {
337 ProfileView(client: client, name: name) 337 ProfileView(client: client, name: name)
338 case .account: 338 case .account:
339 AccountView(client: client) 339 AccountView(client: client)
340 case .tokens:
341 TokensView(client: client)
340 case .addAccount: 342 case .addAccount:
341 SignInView() 343 SignInView()
342 case .notifications: 344 case .notifications:
gitbay/Views/Account/AccountView.swift +2 −2
@@ -1,7 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3/// SSH keys, PGP keys, and email verification. Tokens are minted on the 3/// SSH keys, PGP keys, email verification and notification settings.
4/// web settings page or over SSH, not here. 4/// API tokens have their own screen.
5struct AccountView: View { 5struct AccountView: View {
6 6
7 @State private var model: AccountViewModel 7 @State private var model: AccountViewModel
gitbay/Views/Account/TokensView.swift added +227
@@ -0,0 +1,227 @@
1import SwiftUI
2import UIKit
3import UniformTypeIdentifiers
4
5/// Your API tokens: what each may do, when it was used and stops
6/// working, and who minted it. Mint one from the + button; swipe to revoke.
7struct TokensView: View {
8
9 @State private var model: TokensViewModel
10 @State private var minting = false
11 @State private var minted: String?
12 @State private var revoking: APIToken?
13 /// What the last revoked token had minted, and whether it went too.
14 @State private var revokedReport: String?
15
16 init(client: GitbayClient) {
17 _model = State(initialValue: TokensViewModel(client: client))
18 }
19
20 var body: some View {
21 List {
22 if let error = model.actionError, !minting {
23 Section { GBNotice(error, .gbWarn) }
24 }
25 if let report = revokedReport {
26 Section { GBNotice(report, .gbOK) }
27 }
28 ForEach(model.state.value ?? []) { token in
29 row(token)
30 .swipeActions {
31 Button("Revoke", role: .destructive) { revoking = token }
32 }
33 .contextMenu {
34 Button(role: .destructive) {
35 revoking = token
36 } label: {
37 Label("Revoke", systemImage: "xmark.circle")
38 }
39 }
40 }
41 }
42 .overlay { LoadStateOverlay(state: model.state) }
43 .navigationTitle("API Tokens")
44 .navigationBarTitleDisplayMode(.inline)
45 .toolbar {
46 ToolbarItem(placement: .topBarTrailing) {
47 Button {
48 minting = true
49 } label: {
50 Image(systemName: "plus")
51 }
52 .accessibilityLabel("New Token")
53 .accessibilityIdentifier("token-create")
54 }
55 }
56 .confirmationDialog(
57 revoking.map { "Revoke \($0.name)?" } ?? "",
58 isPresented: Binding(get: { revoking != nil }, set: { if !$0 { revoking = nil } }),
59 titleVisibility: .visible,
60 presenting: revoking
61 ) { token in
62 Button("Revoke", role: .destructive) { revoke(token, withCreated: false) }
63 Button("Revoke with what it created", role: .destructive) { revoke(token, withCreated: true) }
64 Button("Cancel", role: .cancel) {}
65 } message: { _ in
66 Text("Anything using it stops working at once. What it created, tokens and keys, stays unless you revoke it too. If this app signed in with it, or with one of those, the app signs out.")
67 }
68 .sheet(isPresented: $minting, onDismiss: { minted = nil }) {
69 MintTokenSheet(model: model, minted: $minted)
70 }
71 .task { await model.load() }
72 .refreshable { await model.load() }
73 }
74
75 private func row(_ token: APIToken) -> some View {
76 VStack(alignment: .leading, spacing: 3) {
77 HStack(spacing: 6) {
78 Text(token.name)
79 .font(.gbSans(.subheadline).weight(.semibold))
80 GBChip(token.scope, .secondary)
81 if let by = token.createdBy {
82 Text("by \(by)")
83 .font(.gbSans(.caption2))
84 .foregroundStyle(.secondary)
85 }
86 }
87 HStack(spacing: 4) {
88 if let used = token.lastUsedAt {
89 Text("used \(used.formatted(.relative(presentation: .named)))")
90 } else {
91 Text("never used")
92 }
93 Text("·")
94 if let expires = token.expiresAt {
95 if token.isExpired(at: .now) {
96 Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
97 .foregroundStyle(Color.gbBad)
98 } else {
99 Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
100 }
101 } else {
102 Text("does not expire")
103 }
104 }
105 .font(.gbSans(.caption2))
106 .foregroundStyle(.secondary)
107 }
108 .padding(.vertical, 2)
109 .accessibilityElement(children: .combine)
110 .accessibilityIdentifier("token-\(token.name)")
111 }
112
113 private func revoke(_ token: APIToken, withCreated: Bool) {
114 revokedReport = nil
115 Task {
116 guard let created = await model.revoke(token, withCreated: withCreated) else { return }
117 let made = (created.tokens ?? []).map { "token \($0)" } + (created.keys ?? []).map { "key \($0)" }
118 if made.isEmpty {
119 revokedReport = "Revoked \(token.name)."
120 } else if withCreated {
121 revokedReport = "Revoked \(token.name), and what it created: " + made.joined(separator: ", ") + "."
122 } else {
123 revokedReport = "Revoked \(token.name). It created these, still in place: " + made.joined(separator: ", ") + "."
124 }
125 }
126 }
127}
128
129/// Name, scope and lifetime, then the token itself, shown this once.
130private struct MintTokenSheet: View {
131
132 let model: TokensViewModel
133 @Binding var minted: String?
134
135 @State private var name = ""
136 /// read, as the server mints by default; full is a choice.
137 @State private var scope = "read"
138 @State private var ttl: String?
139 @State private var sent = false
140 @State private var copied = false
141 @Environment(\.dismiss) private var dismiss
142
143 var body: some View {
144 NavigationStack {
145 Form {
146 if let minted {
147 Section {
148 Text(minted)
149 .font(.gbMono(.subheadline))
150 .textSelection(.enabled)
151 .accessibilityIdentifier("token-secret")
152 Button(copied ? "Copied" : "Copy") {
153 // This device only, and gone in two minutes: a
154 // token is not for Universal Clipboard or for
155 // whatever is pasted next week.
156 UIPasteboard.general.setItems(
157 [[UTType.utf8PlainText.identifier: minted]],
158 options: [.localOnly: true, .expirationDate: Date.now.addingTimeInterval(120)])
159 copied = true
160 }
161 .accessibilityIdentifier("token-copy")
162 } header: {
163 Text("\(name) · \(scope)")
164 } footer: {
165 Text("This is the only time it is shown. Store it now.")
166 }
167 } else {
168 Section("Name") {
169 TextField("laptop", text: $name)
170 .autocorrectionDisabled()
171 .textInputAutocapitalization(.never)
172 .accessibilityIdentifier("token-name")
173 }
174 Section {
175 Picker("Scope", selection: $scope) {
176 Text("full").tag("full")
177 Text("read").tag("read")
178 }
179 .pickerStyle(.segmented)
180 Picker("Expires", selection: $ttl) {
181 Text("Never").tag(String?.none)
182 Text("In 30 days").tag(String?.some("30d"))
183 Text("In 90 days").tag(String?.some("90d"))
184 Text("In a year").tag(String?.some("365d"))
185 }
186 } footer: {
187 Text("Full scope can change anything you can; read can only look. An expiring token cannot mint tokens or keys.")
188 }
189 if sent, let error = model.actionError {
190 Section { GBNotice(error) }
191 }
192 }
193 }
194 .navigationTitle(minted == nil ? "New Token" : "Your Token")
195 .navigationBarTitleDisplayMode(.inline)
196 .toolbar {
197 if minted != nil {
198 ToolbarItem(placement: .confirmationAction) {
199 Button("Done") { dismiss() }
200 .accessibilityIdentifier("token-done")
201 }
202 } else {
203 ToolbarItem(placement: .cancellationAction) {
204 // Not while minting: the token would be made and
205 // never shown.
206 Button("Cancel") { dismiss() }
207 .disabled(model.working)
208 }
209 ToolbarItem(placement: .confirmationAction) {
210 if model.working {
211 ProgressView()
212 } else {
213 Button("Create") {
214 sent = true
215 Task { minted = await model.create(name: name, scope: scope, ttl: ttl) }
216 }
217 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
218 .accessibilityIdentifier("token-submit")
219 }
220 }
221 }
222 }
223 // The token is on screen once; a swipe must not lose it.
224 .interactiveDismissDisabled(model.working || minted != nil)
225 }
226 }
227}
gitbay/Views/Repos/RepoListView.swift +3
@@ -268,6 +268,9 @@ struct AccountMenu: ToolbarContent {
268 NavigationLink(value: RepoRoute.account) { 268 NavigationLink(value: RepoRoute.account) {
269 Label("Keys & Email", systemImage: "key") 269 Label("Keys & Email", systemImage: "key")
270 } 270 }
271 NavigationLink(value: RepoRoute.tokens) {
272 Label("API Tokens", systemImage: "key.horizontal")
273 }
271 NavigationLink(value: SnippetRoute.list(owner: nil)) { 274 NavigationLink(value: SnippetRoute.list(owner: nil)) {
272 Label("Snippets", systemImage: "scissors") 275 Label("Snippets", systemImage: "scissors")
273 } 276 }
gitbay/Views/Repos/RepoRoute.swift +1
@@ -27,6 +27,7 @@ nonisolated enum RepoRoute: Hashable {
27 case webhookDeliveries(repo: String) 27 case webhookDeliveries(repo: String)
28 case profile(String) 28 case profile(String)
29 case account 29 case account
30 case tokens
30 case addAccount 31 case addAccount
31 case notifications 32 case notifications
32 case admin 33 case admin
gitbayTests/TokenTests.swift added +106
@@ -0,0 +1,106 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func posted(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22private let listJSON = """
23 {"protocol_version":1,"data":[\
24 {"name":"iphone","scope":"full","created_at":"2026-09-01T10:00:00.000Z","last_used_at":"2026-10-14T07:00:00Z"},\
25 {"name":"ci","scope":"read","created_at":"2026-09-02T10:00:00.000Z","expires_at":"2026-01-01T00:00:00Z","created_by":"iphone"}\
26 ],"exit_code":0}
27 """
28
29@MainActor
30struct TokenTests {
31
32 private func loaded() async throws -> (TokensViewModel, StubProtocol.Box) {
33 let (client, stub) = try makeClient()
34 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
35 let model = TokensViewModel(client: client)
36 await model.load()
37 return (model, stub)
38 }
39
40 @Test func tokensListWithUseExpiryAndWhoMadeThem() async throws {
41 let (model, _) = try await loaded()
42 let tokens = try #require(model.state.value)
43 #expect(tokens.map(\.name) == ["iphone", "ci"])
44 #expect(tokens[0].lastUsedAt != nil)
45 #expect(tokens[1].createdBy == "iphone")
46 #expect(tokens[1].isExpired(at: .now))
47 }
48
49 @Test func mintingReturnsTheTokenOnceAndReloads() async throws {
50 let (model, stub) = try await loaded()
51 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"name":"laptop","scope":"read","token":"gbt_secret"},"exit_code":0}"#, match: "cmd"))
52 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
53
54 let minted = await model.create(name: " laptop ", scope: "read", ttl: "30d")
55
56 #expect(minted == "gbt_secret")
57 #expect(try posted(stub) == [["token", "create", "--name", "laptop", "--scope", "read", "--ttl", "30d"]])
58 }
59
60 @Test func aTokenThatNeverExpiresSendsNoTTL() async throws {
61 let (model, stub) = try await loaded()
62 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"name":"x","scope":"full","token":"t"},"exit_code":0}"#, match: "cmd"))
63 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
64
65 _ = await model.create(name: "x", scope: "full", ttl: nil)
66
67 #expect(try posted(stub) == [["token", "create", "--name", "x", "--scope", "full"]])
68 }
69
70 /// An expiring token cannot mint one; the server says so, and the
71 /// app's sentence for that refusal says what to do instead.
72 @Test func anExpiringSessionIsToldWhy() async throws {
73 let (model, stub) = try await loaded()
74 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"token create creates a credential, and the one this request came with expires; use a token or key without an expiry","exit_code":4}"#, match: "cmd"))
75
76 #expect(await model.create(name: "x", scope: "full", ttl: nil) == nil)
77 #expect(model.actionError?.contains("without an expiry") == true)
78 }
79
80 @Test func revokingCanTakeWhatTheTokenCreated() async throws {
81 let (model, stub) = try await loaded()
82 stub.enqueue(.init(status: 200, json: """
83 {"protocol_version":1,"data":{"revoked":"iphone","created":{"tokens":["ci"],"keys":["SHA256:k"]},"created_revoked":true},"exit_code":0}
84 """, match: "cmd"))
85 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
86 let token = try #require(model.state.value?.first)
87
88 let created = await model.revoke(token, withCreated: true)
89
90 #expect(try posted(stub) == [["token", "revoke", "iphone", "--created"]])
91 #expect(created?.tokens == ["ci"])
92 #expect(created?.keys == ["SHA256:k"])
93 }
94
95 @Test func revokingAloneLeavesWhatItCreated() async throws {
96 let (model, stub) = try await loaded()
97 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"revoked":"ci","created":{"tokens":null,"keys":null},"created_revoked":false},"exit_code":0}"#, match: "cmd"))
98 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
99 let token = try #require(model.state.value?.last)
100
101 let created = await model.revoke(token, withCreated: false)
102
103 #expect(try posted(stub) == [["token", "revoke", "ci"]])
104 #expect(created?.isEmpty == true)
105 }
106}