gitbay/Views/Repos/RepoCredentialViews.swift
294 lines · 12071 bytes
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}