Commit 42a9918acf

42a9918acf2b0949061115c3d667ebf375b51c78

parent: 3958f6060f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 03:24 UTC

Secrets and deploy keys: reload after a refusal, a multi-line value, the sheet's own errors

A refused write reloads the list, so a row removed elsewhere goes. A secret can be entered on several lines, for a private key. A sheet shows only the error from its own attempt.

Ref #42
Ref #43

Layout: unified · split

gitbay/Repos/RepoCredentials.swift +6
@@ -54,6 +54,9 @@ final class BuildSecretsViewModel {
54 } catch { 54 } catch {
55 actionError = GitbayError.transport(error).userFacingMessage 55 actionError = GitbayError.transport(error).userFacingMessage
56 } 56 }
57 // A refusal may mean the list changed elsewhere, e.g. a row
58 // removed from another device.
59 await load()
57 return false 60 return false
58 } 61 }
59} 62}
@@ -134,6 +137,9 @@ final class DeployKeysViewModel {
134 } catch { 137 } catch {
135 actionError = GitbayError.transport(error).userFacingMessage 138 actionError = GitbayError.transport(error).userFacingMessage
136 } 139 }
140 // A refusal may mean the list changed elsewhere, e.g. a row
141 // removed from another device.
142 await load()
137 return false 143 return false
138 } 144 }
139} 145}
gitbay/Views/Repos/RepoCredentialViews.swift +28 −8
@@ -68,6 +68,9 @@ private struct SecretSetSheet: View {
68 68
69 @State private var name = "" 69 @State private var name = ""
70 @State private var value = "" 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
71 74
72 var body: some View { 75 var body: some View {
73 NavigationStack { 76 NavigationStack {
@@ -78,14 +81,26 @@ private struct SecretSetSheet: View {
78 .autocorrectionDisabled() 81 .autocorrectionDisabled()
79 .textInputAutocapitalization(.characters) 82 .textInputAutocapitalization(.characters)
80 .accessibilityIdentifier("secret-name") 83 .accessibilityIdentifier("secret-name")
81 SecureField("Value", text: $value) 84 if multiLine {
82 .autocorrectionDisabled() 85 TextEditor(text: $value)
83 .textInputAutocapitalization(.never) 86 .frame(minHeight: 120)
84 .accessibilityIdentifier("secret-value") 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)
85 } footer: { 98 } footer: {
86 Text("Uppercase letters, digits and underscores. The value is sent once and cannot be read back.") 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.")
87 } 102 }
88 if let error = model.actionError { 103 if attempted, let error = model.actionError {
89 Section { GBNotice(error, .gbWarn) } 104 Section { GBNotice(error, .gbWarn) }
90 } 105 }
91 } 106 }
@@ -101,6 +116,7 @@ private struct SecretSetSheet: View {
101 } else { 116 } else {
102 Button("Set") { 117 Button("Set") {
103 Task { 118 Task {
119 attempted = true
104 guard await model.set(name: name, value: value) else { return } 120 guard await model.set(name: name, value: value) else { return }
105 // A secure field that still holds text as the sheet 121 // A secure field that still holds text as the sheet
106 // goes makes iOS offer to save it as a password. 122 // goes makes iOS offer to save it as a password.
@@ -222,6 +238,7 @@ private struct DeployKeyAddSheet: View {
222 @State private var text = "" 238 @State private var text = ""
223 @State private var canPush = false 239 @State private var canPush = false
224 @State private var ttl: String? 240 @State private var ttl: String?
241 @State private var attempted = false
225 242
226 var body: some View { 243 var body: some View {
227 NavigationStack { 244 NavigationStack {
@@ -246,7 +263,7 @@ private struct DeployKeyAddSheet: View {
246 Text("In a year").tag(String?.some("365d")) 263 Text("In a year").tag(String?.some("365d"))
247 } 264 }
248 } 265 }
249 if let error = model.actionError { 266 if attempted, let error = model.actionError {
250 Section { GBNotice(error, .gbWarn) } 267 Section { GBNotice(error, .gbWarn) }
251 } 268 }
252 } 269 }
@@ -261,7 +278,10 @@ private struct DeployKeyAddSheet: View {
261 ProgressView() 278 ProgressView()
262 } else { 279 } else {
263 Button("Add") { 280 Button("Add") {
264 Task { if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() } } 281 Task {
282 attempted = true
283 if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() }
284 }
265 } 285 }
266 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 286 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
267 .accessibilityIdentifier("deploy-key-submit") 287 .accessibilityIdentifier("deploy-key-submit")
gitbayTests/RepoCredentialTests.swift +24
@@ -71,6 +71,30 @@ struct BuildSecretsViewModelTests {
71 #expect(model.actionError == "secret names are env-var shaped: uppercase letters, digits, _") 71 #expect(model.actionError == "secret names are env-var shaped: uppercase letters, digits, _")
72 } 72 }
73 73
74 @Test func aFailedWriteStillRefreshesTheList() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no secret NPM_TOKEN on krz/gitbay","exit_code":3}"#, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":["DEPLOY_TOKEN"],"exit_code":0}"#, match: "argv=list"))
78 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
79
80 await model.remove("NPM_TOKEN")
81
82 #expect(model.actionError == "no secret NPM_TOKEN on krz/gitbay")
83 #expect(model.state.value == ["DEPLOY_TOKEN"])
84 }
85
86 @Test func aMultiLineValueKeepsItsNewlines() async throws {
87 let (client, stub) = try makeClient()
88 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
89 stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list"))
90 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
91 let key = "-----BEGIN KEY-----\nabc\n-----END KEY-----\n"
92
93 #expect(await model.set(name: "BOT_SSH_KEY", value: key))
94
95 #expect(try body(try #require(stub.seen.first))["stdin"] as? String == key)
96 }
97
74 @Test func removeNamesTheSecret() async throws { 98 @Test func removeNamesTheSecret() async throws {
75 let (client, stub) = try makeClient() 99 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) 100 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))