Commit 3337af385a

3337af385a5d67d36b2335ea96c1322c77e1b66b

parent: ac963099bc

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 07:47 UTC

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

API Tokens, in the account menu, lists each token's scope, last use,
expiry and the token that minted it. The + button mints one with a
scope and a lifetime and shows it this once, with a copy button; the
sheet cannot be swiped away while it is on screen. Revoking asks
whether to take the tokens and keys it created too, and says what it
created either way.

Closes #34

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 +217
@@ -0,0 +1,217 @@
1import SwiftUI
2import UIKit
3
4/// Your API tokens: what each may do, when it was used and stops
5/// working, and who minted it. Mint one from the + button; swipe to revoke.
6struct TokensView: View {
7
8 @State private var model: TokensViewModel
9 @State private var minting = false
10 @State private var minted: String?
11 @State private var revoking: APIToken?
12 /// What the last revoked token had minted, and whether it went too.
13 @State private var revokedReport: String?
14
15 init(client: GitbayClient) {
16 _model = State(initialValue: TokensViewModel(client: client))
17 }
18
19 var body: some View {
20 List {
21 if let error = model.actionError, !minting {
22 Section { GBNotice(error, .gbWarn) }
23 }
24 if let report = revokedReport {
25 Section { GBNotice(report, .gbOK) }
26 }
27 ForEach(model.state.value ?? []) { token in
28 row(token)
29 .swipeActions {
30 Button("Revoke", role: .destructive) { revoking = token }
31 }
32 .contextMenu {
33 Button(role: .destructive) {
34 revoking = token
35 } label: {
36 Label("Revoke", systemImage: "xmark.circle")
37 }
38 }
39 }
40 }
41 .overlay { LoadStateOverlay(state: model.state) }
42 .navigationTitle("API Tokens")
43 .navigationBarTitleDisplayMode(.inline)
44 .toolbar {
45 ToolbarItem(placement: .topBarTrailing) {
46 Button {
47 minting = true
48 } label: {
49 Image(systemName: "plus")
50 }
51 .accessibilityLabel("New Token")
52 .accessibilityIdentifier("token-create")
53 }
54 }
55 .confirmationDialog(
56 revoking.map { "Revoke \($0.name)?" } ?? "",
57 isPresented: Binding(get: { revoking != nil }, set: { if !$0 { revoking = nil } }),
58 titleVisibility: .visible,
59 presenting: revoking
60 ) { token in
61 Button("Revoke", role: .destructive) { revoke(token, withCreated: false) }
62 Button("Revoke with what it created", role: .destructive) { revoke(token, withCreated: true) }
63 Button("Cancel", role: .cancel) {}
64 } message: { _ in
65 Text("Anything using it stops working at once. If this app signed in with it, the app signs out. What it created, tokens and keys, stays unless you revoke it too.")
66 }
67 .sheet(isPresented: $minting, onDismiss: { minted = nil }) {
68 MintTokenSheet(model: model, minted: $minted)
69 }
70 .task { await model.load() }
71 .refreshable { await model.load() }
72 }
73
74 private func row(_ token: APIToken) -> some View {
75 VStack(alignment: .leading, spacing: 3) {
76 HStack(spacing: 6) {
77 Text(token.name)
78 .font(.gbSans(.subheadline).weight(.semibold))
79 GBChip(token.scope, .secondary)
80 if let by = token.createdBy {
81 Text("by \(by)")
82 .font(.gbSans(.caption2))
83 .foregroundStyle(.secondary)
84 }
85 }
86 HStack(spacing: 4) {
87 if let used = token.lastUsedAt {
88 Text("used \(used.formatted(.relative(presentation: .named)))")
89 } else {
90 Text("never used")
91 }
92 Text("·")
93 if let expires = token.expiresAt {
94 if token.isExpired(at: .now) {
95 Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
96 .foregroundStyle(Color.gbBad)
97 } else {
98 Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
99 }
100 } else {
101 Text("does not expire")
102 }
103 }
104 .font(.gbSans(.caption2))
105 .foregroundStyle(.secondary)
106 }
107 .padding(.vertical, 2)
108 .accessibilityElement(children: .combine)
109 .accessibilityIdentifier("token-\(token.name)")
110 }
111
112 private func revoke(_ token: APIToken, withCreated: Bool) {
113 revokedReport = nil
114 Task {
115 guard let created = await model.revoke(token, withCreated: withCreated) else { return }
116 let made = (created.tokens ?? []).map { "token \($0)" } + (created.keys ?? []).map { "key \($0)" }
117 if made.isEmpty {
118 revokedReport = "Revoked \(token.name)."
119 } else if withCreated {
120 revokedReport = "Revoked \(token.name), and what it created: " + made.joined(separator: ", ") + "."
121 } else {
122 revokedReport = "Revoked \(token.name). It created these, still in place: " + made.joined(separator: ", ") + "."
123 }
124 }
125 }
126}
127
128/// Name, scope and lifetime, then the token itself, shown this once.
129private struct MintTokenSheet: View {
130
131 let model: TokensViewModel
132 @Binding var minted: String?
133
134 @State private var name = ""
135 @State private var scope = "full"
136 @State private var ttl: String?
137 @State private var sent = false
138 @State private var copied = false
139 @Environment(\.dismiss) private var dismiss
140
141 var body: some View {
142 NavigationStack {
143 Form {
144 if let minted {
145 Section {
146 Text(minted)
147 .font(.gbMono(.subheadline))
148 .textSelection(.enabled)
149 .accessibilityIdentifier("token-secret")
150 Button(copied ? "Copied" : "Copy") {
151 UIPasteboard.general.string = minted
152 copied = true
153 }
154 .accessibilityIdentifier("token-copy")
155 } header: {
156 Text("\(name) · \(scope)")
157 } footer: {
158 Text("This is the only time it is shown. Store it now.")
159 }
160 } else {
161 Section("Name") {
162 TextField("laptop", text: $name)
163 .autocorrectionDisabled()
164 .textInputAutocapitalization(.never)
165 .accessibilityIdentifier("token-name")
166 }
167 Section {
168 Picker("Scope", selection: $scope) {
169 Text("full").tag("full")
170 Text("read").tag("read")
171 }
172 .pickerStyle(.segmented)
173 Picker("Expires", selection: $ttl) {
174 Text("Never").tag(String?.none)
175 Text("In 30 days").tag(String?.some("30d"))
176 Text("In 90 days").tag(String?.some("90d"))
177 Text("In a year").tag(String?.some("365d"))
178 }
179 } footer: {
180 Text("Full scope can change anything you can; read can only look. An expiring token cannot mint tokens or keys.")
181 }
182 if sent, let error = model.actionError {
183 Section { GBNotice(error) }
184 }
185 }
186 }
187 .navigationTitle(minted == nil ? "New Token" : "Your Token")
188 .navigationBarTitleDisplayMode(.inline)
189 .toolbar {
190 if minted != nil {
191 ToolbarItem(placement: .confirmationAction) {
192 Button("Done") { dismiss() }
193 .accessibilityIdentifier("token-done")
194 }
195 } else {
196 ToolbarItem(placement: .cancellationAction) {
197 Button("Cancel") { dismiss() }
198 }
199 ToolbarItem(placement: .confirmationAction) {
200 if model.working {
201 ProgressView()
202 } else {
203 Button("Create") {
204 sent = true
205 Task { minted = await model.create(name: name, scope: scope, ttl: ttl) }
206 }
207 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
208 .accessibilityIdentifier("token-submit")
209 }
210 }
211 }
212 }
213 // The token is on screen once; a swipe must not lose it.
214 .interactiveDismissDisabled(model.working || minted != nil)
215 }
216 }
217}
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}