gitbay/Views/Repos/RepoCredentialViews.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Repos/RepoCredentialViews.swift history · blame · raw

294 lines · 12071 bytes

5 symbols in this file
  1import SwiftUI
  2
  3/// A repository's build secrets: their names, a way to set or replace
  4/// one, and removal. Values are never shown, here or anywhere.
  5struct BuildSecretsView: View {
  6
  7    @State private var model: BuildSecretsViewModel
  8    @State private var adding = false
  9    @State private var removing: String?
 10
 11    init(client: GitbayClient, repo: String) {
 12        _model = State(initialValue: BuildSecretsViewModel(client: client, repoPath: repo))
 13    }
 14
 15    var body: some View {
 16        List {
 17            if let error = model.actionError, !adding {
 18                Section { GBNotice(error, .gbWarn) }
 19            }
 20            Section {
 21                ForEach(model.state.value ?? [], id: \.self) { name in
 22                    Text(name)
 23                        .font(.gbMono(.subheadline))
 24                        .swipeActions {
 25                            Button("Remove", role: .destructive) { removing = name }
 26                                .disabled(model.working)
 27                        }
 28                }
 29            } footer: {
 30                if model.state.value != nil {
 31                    Text("Builds read each secret as an environment variable. Setting a name again replaces its value.")
 32                }
 33            }
 34        }
 35        .overlay { LoadStateOverlay(state: model.state) }
 36        .navigationTitle("Build Secrets")
 37        .navigationBarTitleDisplayMode(.inline)
 38        .toolbar {
 39            ToolbarItem(placement: .topBarTrailing) {
 40                Button { adding = true } label: { Image(systemName: "plus") }
 41                    .accessibilityLabel("Set Secret")
 42                    .disabled(model.working)
 43                    .accessibilityIdentifier("secret-add-button")
 44            }
 45        }
 46        .sheet(isPresented: $adding) {
 47            SecretSetSheet(model: model) { adding = false }
 48        }
 49        .confirmationDialog(
 50            "Remove \(removing ?? "")? Builds that read it will no longer find it.",
 51            isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
 52            titleVisibility: .visible
 53        ) {
 54            Button("Remove", role: .destructive) {
 55                if let name = removing { Task { await model.remove(name) } }
 56                removing = nil
 57            }
 58            Button("Cancel", role: .cancel) {}
 59        }
 60        .task { await model.load() }
 61        .refreshable { await model.load() }
 62    }
 63}
 64
 65private struct SecretSetSheet: View {
 66    let model: BuildSecretsViewModel
 67    let onDone: () -> Void
 68
 69    @State private var name = ""
 70    @State private var value = ""
 71    @State private var multiLine = false
 72    /// Errors shown here are this sheet's own, not an earlier list action's.
 73    @State private var attempted = false
 74
 75    var body: some View {
 76        NavigationStack {
 77            Form {
 78                Section {
 79                    TextField("NAME", text: $name)
 80                        .font(.gbMono(.body))
 81                        .autocorrectionDisabled()
 82                        .textInputAutocapitalization(.characters)
 83                        .accessibilityIdentifier("secret-name")
 84                    if multiLine {
 85                        TextEditor(text: $value)
 86                            .frame(minHeight: 120)
 87                            .font(.gbMono(.caption))
 88                            .autocorrectionDisabled()
 89                            .textInputAutocapitalization(.never)
 90                            .accessibilityIdentifier("secret-value-multiline")
 91                    } else {
 92                        SecureField("Value", text: $value)
 93                            .autocorrectionDisabled()
 94                            .textInputAutocapitalization(.never)
 95                            .accessibilityIdentifier("secret-value")
 96                    }
 97                    Toggle("Multi-line value", isOn: $multiLine)
 98                } footer: {
 99                    Text(multiLine
100                        ? "Shown while you type, for a value with line breaks such as a private key. It is sent once and cannot be read back."
101                        : "Uppercase letters, digits and underscores. The value is sent once and cannot be read back.")
102                }
103                if attempted, let error = model.actionError {
104                    Section { GBNotice(error, .gbWarn) }
105                }
106            }
107            .navigationTitle("Set Secret")
108            .navigationBarTitleDisplayMode(.inline)
109            .toolbar {
110                ToolbarItem(placement: .cancellationAction) {
111                    Button("Cancel") { onDone() }.disabled(model.working)
112                }
113                ToolbarItem(placement: .confirmationAction) {
114                    if model.working {
115                        ProgressView()
116                    } else {
117                        Button("Set") {
118                            Task {
119                                attempted = true
120                                guard await model.set(name: name, value: value) else { return }
121                                // A secure field that still holds text as the sheet
122                                // goes makes iOS offer to save it as a password.
123                                value = ""
124                                await Task.yield()
125                                onDone()
126                            }
127                        }
128                        .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || value.isEmpty)
129                        .accessibilityIdentifier("secret-submit")
130                    }
131                }
132            }
133            .interactiveDismissDisabled(model.working)
134        }
135    }
136}
137
138/// A repository's deploy keys: keys bound to it alone, read-only unless
139/// they may push, with when each was used and when it expires.
140struct DeployKeysView: View {
141
142    @State private var model: DeployKeysViewModel
143    @State private var adding = false
144    @State private var removing: DeployKey?
145
146    init(client: GitbayClient, repo: String) {
147        _model = State(initialValue: DeployKeysViewModel(client: client, repoPath: repo))
148    }
149
150    var body: some View {
151        List {
152            if let error = model.actionError, !adding {
153                Section { GBNotice(error, .gbWarn) }
154            }
155            Section {
156                ForEach(model.state.value ?? []) { key in
157                    VStack(alignment: .leading, spacing: 3) {
158                        if !key.label.isEmpty {
159                            Text(key.label).font(.gbSans(.subheadline))
160                        }
161                        Text(key.fingerprint)
162                            .font(.gbMono(.caption))
163                            .lineLimit(1)
164                            .truncationMode(.middle)
165                        HStack(spacing: 6) {
166                            GBChip(key.canPush ? "read-write" : "read-only", key.canPush ? Color.gbWarn : Color.secondary)
167                            dates(key)
168                        }
169                    }
170                    .padding(.vertical, 2)
171                    .accessibilityElement(children: .combine)
172                    .swipeActions {
173                        Button("Remove", role: .destructive) { removing = key }
174                            .disabled(model.working)
175                    }
176                }
177            } footer: {
178                if model.state.value != nil {
179                    Text("A deploy key reaches this repository and nothing else. A read-write key can push.")
180                }
181            }
182        }
183        .overlay { LoadStateOverlay(state: model.state) }
184        .navigationTitle("Deploy Keys")
185        .navigationBarTitleDisplayMode(.inline)
186        .toolbar {
187            ToolbarItem(placement: .topBarTrailing) {
188                Button { adding = true } label: { Image(systemName: "plus") }
189                    .accessibilityLabel("Add Deploy Key")
190                    .disabled(model.working)
191                    .accessibilityIdentifier("deploy-key-add-button")
192            }
193        }
194        .sheet(isPresented: $adding) {
195            DeployKeyAddSheet(model: model) { adding = false }
196        }
197        .confirmationDialog(
198            "Remove the deploy key \(removing.map { $0.label.isEmpty ? $0.fingerprint : $0.label } ?? "")?",
199            isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
200            titleVisibility: .visible
201        ) {
202            Button("Remove", role: .destructive) {
203                if let key = removing { Task { await model.remove(key) } }
204                removing = nil
205            }
206            Button("Cancel", role: .cancel) {}
207        }
208        .task { await model.load() }
209        .refreshable { await model.load() }
210    }
211
212    private func dates(_ key: DeployKey) -> some View {
213        HStack(spacing: 4) {
214            if let used = key.lastUsedAt {
215                Text("used \(used.formatted(.relative(presentation: .named)))")
216            } else {
217                Text("never used")
218            }
219            if let expires = key.expiresAt {
220                Text("·")
221                if expires <= .now {
222                    Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
223                        .foregroundStyle(Color.gbBad)
224                } else {
225                    Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
226                }
227            }
228        }
229        .font(.gbSans(.caption2))
230        .foregroundStyle(.secondary)
231    }
232}
233
234private struct DeployKeyAddSheet: View {
235    let model: DeployKeysViewModel
236    let onDone: () -> Void
237
238    @State private var text = ""
239    @State private var canPush = false
240    @State private var ttl: String?
241    @State private var attempted = false
242
243    var body: some View {
244        NavigationStack {
245            Form {
246                Section {
247                    TextEditor(text: $text)
248                        .frame(minHeight: 120)
249                        .font(.gbMono(.caption))
250                        .autocorrectionDisabled()
251                        .textInputAutocapitalization(.never)
252                        .accessibilityIdentifier("deploy-key-text")
253                } footer: {
254                    Text("Paste the public key, as in authorized_keys. Its comment becomes the label.")
255                }
256                Section {
257                    Toggle("Allow pushes", isOn: $canPush)
258                        .accessibilityIdentifier("deploy-key-rw")
259                    Picker("Expires", selection: $ttl) {
260                        Text("Never").tag(String?.none)
261                        Text("In 30 days").tag(String?.some("30d"))
262                        Text("In 90 days").tag(String?.some("90d"))
263                        Text("In a year").tag(String?.some("365d"))
264                    }
265                }
266                if attempted, let error = model.actionError {
267                    Section { GBNotice(error, .gbWarn) }
268                }
269            }
270            .navigationTitle("Add Deploy Key")
271            .navigationBarTitleDisplayMode(.inline)
272            .toolbar {
273                ToolbarItem(placement: .cancellationAction) {
274                    Button("Cancel") { onDone() }.disabled(model.working)
275                }
276                ToolbarItem(placement: .confirmationAction) {
277                    if model.working {
278                        ProgressView()
279                    } else {
280                        Button("Add") {
281                            Task {
282                                attempted = true
283                                if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() }
284                            }
285                        }
286                        .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
287                        .accessibilityIdentifier("deploy-key-submit")
288                    }
289                }
290            }
291            .interactiveDismissDisabled(model.working)
292        }
293    }
294}