import SwiftUI import UIKit import UniformTypeIdentifiers /// Your API tokens: what each may do, when it was used and stops /// working, and who minted it. Mint one from the + button; swipe to revoke. struct TokensView: View { @State private var model: TokensViewModel @State private var minting = false @State private var minted: String? @State private var revoking: APIToken? /// What the last revoked token had minted, and whether it went too. @State private var revokedReport: String? init(client: GitbayClient) { _model = State(initialValue: TokensViewModel(client: client)) } var body: some View { List { if let error = model.actionError, !minting { Section { GBNotice(error, .gbWarn) } } if let report = revokedReport { Section { GBNotice(report, .gbOK) } } ForEach(model.state.value ?? []) { token in row(token) .swipeActions { Button("Revoke", role: .destructive) { revoking = token } } .contextMenu { Button(role: .destructive) { revoking = token } label: { Label("Revoke", systemImage: "xmark.circle") } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("API Tokens") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { minting = true } label: { Image(systemName: "plus") } .accessibilityLabel("New Token") .accessibilityIdentifier("token-create") } } .confirmationDialog( revoking.map { "Revoke \($0.name)?" } ?? "", isPresented: Binding(get: { revoking != nil }, set: { if !$0 { revoking = nil } }), titleVisibility: .visible, presenting: revoking ) { token in Button("Revoke", role: .destructive) { revoke(token, withCreated: false) } Button("Revoke with what it created", role: .destructive) { revoke(token, withCreated: true) } Button("Cancel", role: .cancel) {} } message: { _ in 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.") } .sheet(isPresented: $minting, onDismiss: { minted = nil }) { MintTokenSheet(model: model, minted: $minted) } .task { await model.load() } .refreshable { await model.load() } } private func row(_ token: APIToken) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(token.name) .font(.gbSans(.subheadline).weight(.semibold)) GBChip(token.scope, .secondary) if let by = token.createdBy { Text("by \(by)") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } HStack(spacing: 4) { if let used = token.lastUsedAt { Text("used \(used.formatted(.relative(presentation: .named)))") } else { Text("never used") } Text("·") if let expires = token.expiresAt { if token.isExpired(at: .now) { Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") .foregroundStyle(Color.gbBad) } else { Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") } } else { Text("does not expire") } } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } .padding(.vertical, 2) .accessibilityElement(children: .combine) .accessibilityIdentifier("token-\(token.name)") } private func revoke(_ token: APIToken, withCreated: Bool) { revokedReport = nil Task { guard let created = await model.revoke(token, withCreated: withCreated) else { return } let made = (created.tokens ?? []).map { "token \($0)" } + (created.keys ?? []).map { "key \($0)" } if made.isEmpty { revokedReport = "Revoked \(token.name)." } else if withCreated { revokedReport = "Revoked \(token.name), and what it created: " + made.joined(separator: ", ") + "." } else { revokedReport = "Revoked \(token.name). It created these, still in place: " + made.joined(separator: ", ") + "." } } } } /// Name, scope and lifetime, then the token itself, shown this once. private struct MintTokenSheet: View { let model: TokensViewModel @Binding var minted: String? @State private var name = "" /// read, as the server mints by default; full is a choice. @State private var scope = "read" @State private var ttl: String? @State private var sent = false @State private var copied = false @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { if let minted { Section { Text(minted) .font(.gbMono(.subheadline)) .textSelection(.enabled) .accessibilityIdentifier("token-secret") Button(copied ? "Copied" : "Copy") { // This device only, and gone in two minutes: a // token is not for Universal Clipboard or for // whatever is pasted next week. UIPasteboard.general.setItems( [[UTType.utf8PlainText.identifier: minted]], options: [.localOnly: true, .expirationDate: Date.now.addingTimeInterval(120)]) copied = true } .accessibilityIdentifier("token-copy") } header: { Text("\(name) · \(scope)") } footer: { Text("This is the only time it is shown. Store it now.") } } else { Section("Name") { TextField("laptop", text: $name) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("token-name") } Section { Picker("Scope", selection: $scope) { Text("full").tag("full") Text("read").tag("read") } .pickerStyle(.segmented) Picker("Expires", selection: $ttl) { Text("Never").tag(String?.none) Text("In 30 days").tag(String?.some("30d")) Text("In 90 days").tag(String?.some("90d")) Text("In a year").tag(String?.some("365d")) } } footer: { Text("Full scope can change anything you can; read can only look. An expiring token cannot mint tokens or keys.") } if sent, let error = model.actionError { Section { GBNotice(error) } } } } .navigationTitle(minted == nil ? "New Token" : "Your Token") .navigationBarTitleDisplayMode(.inline) .toolbar { if minted != nil { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } .accessibilityIdentifier("token-done") } } else { ToolbarItem(placement: .cancellationAction) { // Not while minting: the token would be made and // never shown. Button("Cancel") { dismiss() } .disabled(model.working) } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Create") { sent = true Task { minted = await model.create(name: name, scope: scope, ttl: ttl) } } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("token-submit") } } } } // The token is on screen once; a swipe must not lose it. .interactiveDismissDisabled(model.working || minted != nil) } } }