Commit 3337af385a
Verified · cmc
Layout: unified · split
gitbay/Account/TokensViewModel.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 6 | nonisolated 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 | |
| 33 | final 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 | 337 | ProfileView(client: client, name: name) |
| 338 | 338 | case .account: |
| 339 | 339 | AccountView(client: client) |
| 340 | case .tokens: | |
| 341 | TokensView(client: client) | |
| 340 | 342 | case .addAccount: |
| 341 | 343 | SignInView() |
| 342 | 344 | case .notifications: |
gitbay/Views/Account/AccountView.swift +2 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | /// SSH keys, PGP keys, and email verification. Tokens are minted on the | |
| 4 | /// web settings page or over SSH, not here. | |
| 3 | /// SSH keys, PGP keys, email verification and notification settings. | |
| 4 | /// API tokens have their own screen. | |
| 5 | 5 | struct AccountView: View { |
| 6 | 6 | |
| 7 | 7 | @State private var model: AccountViewModel |
gitbay/Views/Account/TokensView.swift added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | import SwiftUI | |
| 2 | import 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. | |
| 6 | struct 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. | |
| 129 | private 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 | 268 | NavigationLink(value: RepoRoute.account) { |
| 269 | 269 | Label("Keys & Email", systemImage: "key") |
| 270 | 270 | } |
| 271 | NavigationLink(value: RepoRoute.tokens) { | |
| 272 | Label("API Tokens", systemImage: "key.horizontal") | |
| 273 | } | |
| 271 | 274 | NavigationLink(value: SnippetRoute.list(owner: nil)) { |
| 272 | 275 | Label("Snippets", systemImage: "scissors") |
| 273 | 276 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -27,6 +27,7 @@ nonisolated enum RepoRoute: Hashable { | ||
| 27 | 27 | case webhookDeliveries(repo: String) |
| 28 | 28 | case profile(String) |
| 29 | 29 | case account |
| 30 | case tokens | |
| 30 | 31 | case addAccount |
| 31 | 32 | case notifications |
| 32 | 33 | case admin |
gitbayTests/TokenTests.swift added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 22 | private 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 | |
| 30 | struct 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 | } | |