Commit 507e2e3ccc

507e2e3cccd0f616362cc8f53abe205bf12f095f

parent: ea6bf73f6d

Unsigned

cmc <hello@cleberg.net> · 2026-09-19 02:50 UTC

SSH key labels (!102)

Parity row "SSH key label" (krz/gitbay ad34785). `keys add --label` from the paste sheet, `keys label <fingerprint> [text]` from a swipe action; the label leads the row when set and an empty label clears it.

Layout: unified · split

gitbay/Account/AccountViewModel.swift +18 −5
@@ -11,6 +11,8 @@ final class AccountViewModel {
11 let fingerprint: String 11 let fingerprint: String
12 let algo: String 12 let algo: String
13 let scope: String 13 let scope: String
14 /// A name for the key (v1.21.0); omitted when none is set.
15 let label: String?
14 var id: String { fingerprint } 16 var id: String { fingerprint }
15 } 17 }
16 18
@@ -125,11 +127,22 @@ final class AccountViewModel {
125 127
126 // MARK: - SSH keys 128 // MARK: - SSH keys
127 129
128 /// `keys add [--scope full|git]` — the authorized_keys line travels 130 /// `keys add [--scope full|git] [--label <text>]` — the
129 /// as raw stdin; a public key is not a secret. 131 /// authorized_keys line travels as raw stdin; a public key is not a
130 func addSSHKey(_ publicKey: String, scope: String) async { 132 /// secret. A blank label sends no flag.
131 await perform(["keys", "add", "--scope", scope], 133 func addSSHKey(_ publicKey: String, scope: String, label: String = "") async {
132 stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines)) 134 var argv = ["keys", "add", "--scope", scope]
135 let label = label.trimmingCharacters(in: .whitespaces)
136 if !label.isEmpty { argv.append(contentsOf: ["--label", label]) }
137 await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
138 }
139
140 /// `keys label <fingerprint> [<text>]`: no text clears the label.
141 func labelSSHKey(_ key: SSHKey, _ text: String) async {
142 var argv = ["keys", "label", key.fingerprint]
143 let text = text.trimmingCharacters(in: .whitespaces)
144 if !text.isEmpty { argv.append(text) }
145 await perform(argv)
133 } 146 }
134 147
135 func removeSSHKey(_ key: SSHKey) async { 148 func removeSSHKey(_ key: SSHKey) async {
gitbay/Views/Account/AccountView.swift +41 −5
@@ -11,6 +11,8 @@ struct AccountView: View {
11 @State private var verifyCode = "" 11 @State private var verifyCode = ""
12 @State private var removingEmail: AccountViewModel.EmailAddress? 12 @State private var removingEmail: AccountViewModel.EmailAddress?
13 @State private var removingSSH: AccountViewModel.SSHKey? 13 @State private var removingSSH: AccountViewModel.SSHKey?
14 @State private var labellingSSH: AccountViewModel.SSHKey?
15 @State private var sshLabelText = ""
14 @State private var removingPGP: AccountViewModel.PGPKey? 16 @State private var removingPGP: AccountViewModel.PGPKey?
15 17
16 init(client: GitbayClient) { 18 init(client: GitbayClient) {
@@ -87,9 +89,9 @@ struct AccountView: View {
87 scopes: ["full", "git"], 89 scopes: ["full", "git"],
88 working: model.working, 90 working: model.working,
89 errorMessage: model.actionError 91 errorMessage: model.actionError
90 ) { text, scope in 92 ) { text, scope, label in
91 Task { 93 Task {
92 await model.addSSHKey(text, scope: scope ?? "full") 94 await model.addSSHKey(text, scope: scope ?? "full", label: label)
93 if model.actionError == nil { addingSSH = false } 95 if model.actionError == nil { addingSSH = false }
94 } 96 }
95 } 97 }
@@ -101,13 +103,32 @@ struct AccountView: View {
101 scopes: nil, 103 scopes: nil,
102 working: model.working, 104 working: model.working,
103 errorMessage: model.actionError 105 errorMessage: model.actionError
104 ) { text, _ in 106 ) { text, _, _ in
105 Task { 107 Task {
106 await model.addPGPKey(text) 108 await model.addPGPKey(text)
107 if model.actionError == nil { addingPGP = false } 109 if model.actionError == nil { addingPGP = false }
108 } 110 }
109 } 111 }
110 } 112 }
113 .alert(
114 "Label this key",
115 isPresented: Binding(
116 get: { labellingSSH != nil },
117 set: { if !$0 { labellingSSH = nil } }
118 ),
119 presenting: labellingSSH
120 ) { key in
121 TextField("Label", text: $sshLabelText)
122 .autocorrectionDisabled()
123 .textInputAutocapitalization(.never)
124 Button("Save") {
125 Task { await model.labelSSHKey(key, sshLabelText) }
126 labellingSSH = nil
127 }
128 Button("Cancel", role: .cancel) { labellingSSH = nil }
129 } message: { _ in
130 Text("An empty label clears it.")
131 }
111 .confirmationDialog( 132 .confirmationDialog(
112 "Remove this SSH key? Anything authenticating with it loses access.", 133 "Remove this SSH key? Anything authenticating with it loses access.",
113 isPresented: Binding( 134 isPresented: Binding(
@@ -171,6 +192,10 @@ struct AccountView: View {
171 Section { 192 Section {
172 ForEach(keys) { key in 193 ForEach(keys) { key in
173 VStack(alignment: .leading, spacing: 2) { 194 VStack(alignment: .leading, spacing: 2) {
195 if let label = key.label, !label.isEmpty {
196 Text(label)
197 .font(.gbSans(.subheadline).weight(.medium))
198 }
174 Text(key.fingerprint) 199 Text(key.fingerprint)
175 .font(.gbMono(.caption)) 200 .font(.gbMono(.caption))
176 .lineLimit(1) 201 .lineLimit(1)
@@ -186,6 +211,11 @@ struct AccountView: View {
186 Button("Remove", role: .destructive) { 211 Button("Remove", role: .destructive) {
187 removingSSH = key 212 removingSSH = key
188 } 213 }
214 Button("Label") {
215 sshLabelText = key.label ?? ""
216 labellingSSH = key
217 }
218 .tint(.gbAccent)
189 } 219 }
190 } 220 }
191 Button { 221 Button {
@@ -308,11 +338,13 @@ private struct KeyPasteSheet: View {
308 let scopes: [String]? 338 let scopes: [String]?
309 let working: Bool 339 let working: Bool
310 let errorMessage: String? 340 let errorMessage: String?
311 let onSubmit: (String, String?) -> Void 341 /// Text, scope (nil without scopes), label (empty without scopes).
342 let onSubmit: (String, String?, String) -> Void
312 343
313 @Environment(\.dismiss) private var dismiss 344 @Environment(\.dismiss) private var dismiss
314 @State private var text = "" 345 @State private var text = ""
315 @State private var scope = "full" 346 @State private var scope = "full"
347 @State private var label = ""
316 348
317 var body: some View { 349 var body: some View {
318 NavigationStack { 350 NavigationStack {
@@ -333,6 +365,10 @@ private struct KeyPasteSheet: View {
333 ForEach(scopes, id: \.self) { Text($0).tag($0) } 365 ForEach(scopes, id: \.self) { Text($0).tag($0) }
334 } 366 }
335 .pickerStyle(.segmented) 367 .pickerStyle(.segmented)
368 TextField("Label (optional)", text: $label)
369 .autocorrectionDisabled()
370 .textInputAutocapitalization(.never)
371 .accessibilityIdentifier("key-paste-label")
336 } 372 }
337 } 373 }
338 if let errorMessage { 374 if let errorMessage {
@@ -352,7 +388,7 @@ private struct KeyPasteSheet: View {
352 ProgressView() 388 ProgressView()
353 } else { 389 } else {
354 Button("Add") { 390 Button("Add") {
355 onSubmit(text, scopes != nil ? scope : nil) 391 onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "")
356 } 392 }
357 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 393 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
358 .accessibilityIdentifier("key-paste-submit") 394 .accessibilityIdentifier("key-paste-submit")
gitbayTests/AccountTests.swift +45 −1
@@ -19,7 +19,8 @@ private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
19 19
20private let keysJSON = """ 20private let keysJSON = """
21 {"protocol_version":1,"data":[\ 21 {"protocol_version":1,"data":[\
22 {"fingerprint":"SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY","algo":"ssh-ed25519","scope":"full"}\ 22 {"fingerprint":"SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY","algo":"ssh-ed25519","scope":"full",\
23 "label":"cmc@mac"}\
23 ],"exit_code":0} 24 ],"exit_code":0}
24 """ 25 """
25private let pgpJSON = """ 26private let pgpJSON = """
@@ -171,6 +172,49 @@ struct AccountViewModelTests {
171 #expect(stdin == "ssh-ed25519 AAAAC3Nza phone") 172 #expect(stdin == "ssh-ed25519 AAAAC3Nza phone")
172 } 173 }
173 174
175 /// v1.21.0: a key carries a label. It is omitempty, so an unlabelled
176 /// key has no key at all.
177 @Test func sshKeyLabelDecodes() async throws {
178 let (model, _) = try await loadedModel()
179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac")
180 }
181
182 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
183 let (model, stub) = try await loadedModel()
184 for _ in 0..<2 {
185 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
186 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
187 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
188 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
189 }
190
191 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone", scope: "git", label: "phone")
192 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone", scope: "git", label: " ")
193
194 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
195 #expect(writes[0] == ["keys", "add", "--scope", "git", "--label", "phone"])
196 #expect(writes[1] == ["keys", "add", "--scope", "git"])
197 }
198
199 /// `keys label <fingerprint> [<text>]`: no text clears the label.
200 @Test func labellingAKeySendsTheTextAndAnEmptyTextClears() async throws {
201 let (model, stub) = try await loadedModel()
202 for _ in 0..<2 {
203 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
204 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
205 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
206 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
207 }
208 let key = try #require(model.state.value).sshKeys[0]
209
210 await model.labelSSHKey(key, "homelab")
211 await model.labelSSHKey(key, " ")
212
213 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
214 #expect(writes[0] == ["keys", "label", key.fingerprint, "homelab"])
215 #expect(writes[1] == ["keys", "label", key.fingerprint])
216 }
217
174 @Test func removalsTargetTheFingerprint() async throws { 218 @Test func removalsTargetTheFingerprint() async throws {
175 let (model, stub) = try await loadedModel() 219 let (model, stub) = try await loadedModel()
176 for _ in 0..<2 { 220 for _ in 0..<2 {