gitbay/Views/Account/TokensView.swift

v1.13.0
gitbay-ios/gitbay/Views/Account/TokensView.swift history · blame · raw

227 lines · 9715 bytes

  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}