gitbay/Views/Account/TokensView.swift
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}