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 {
1111 let fingerprint: String
1212 let algo: String
1313 let scope: String
14 /// A name for the key (v1.21.0); omitted when none is set.
15 let label: String?
1416 var id: String { fingerprint }
1517 }
1618
@@ -125,11 +127,22 @@ final class AccountViewModel {
125127
126128 // MARK: - SSH keys
127129
128 /// `keys add [--scope full|git]` — the authorized_keys line travels
129 /// as raw stdin; a public key is not a secret.
130 func addSSHKey(_ publicKey: String, scope: String) async {
131 await perform(["keys", "add", "--scope", scope],
132 stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
130 /// `keys add [--scope full|git] [--label <text>]` — the
131 /// authorized_keys line travels as raw stdin; a public key is not a
132 /// secret. A blank label sends no flag.
133 func addSSHKey(_ publicKey: String, scope: String, label: String = "") async {
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)
133146 }
134147
135148 func removeSSHKey(_ key: SSHKey) async {
gitbay/Views/Account/AccountView.swift +41 −5
@@ -11,6 +11,8 @@ struct AccountView: View {
1111 @State private var verifyCode = ""
1212 @State private var removingEmail: AccountViewModel.EmailAddress?
1313 @State private var removingSSH: AccountViewModel.SSHKey?
14 @State private var labellingSSH: AccountViewModel.SSHKey?
15 @State private var sshLabelText = ""
1416 @State private var removingPGP: AccountViewModel.PGPKey?
1517
1618 init(client: GitbayClient) {
@@ -87,9 +89,9 @@ struct AccountView: View {
8789 scopes: ["full", "git"],
8890 working: model.working,
8991 errorMessage: model.actionError
90 ) { text, scope in
92 ) { text, scope, label in
9193 Task {
92 await model.addSSHKey(text, scope: scope ?? "full")
94 await model.addSSHKey(text, scope: scope ?? "full", label: label)
9395 if model.actionError == nil { addingSSH = false }
9496 }
9597 }
@@ -101,13 +103,32 @@ struct AccountView: View {
101103 scopes: nil,
102104 working: model.working,
103105 errorMessage: model.actionError
104 ) { text, _ in
106 ) { text, _, _ in
105107 Task {
106108 await model.addPGPKey(text)
107109 if model.actionError == nil { addingPGP = false }
108110 }
109111 }
110112 }
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 }
111132 .confirmationDialog(
112133 "Remove this SSH key? Anything authenticating with it loses access.",
113134 isPresented: Binding(
@@ -171,6 +192,10 @@ struct AccountView: View {
171192 Section {
172193 ForEach(keys) { key in
173194 VStack(alignment: .leading, spacing: 2) {
195 if let label = key.label, !label.isEmpty {
196 Text(label)
197 .font(.gbSans(.subheadline).weight(.medium))
198 }
174199 Text(key.fingerprint)
175200 .font(.gbMono(.caption))
176201 .lineLimit(1)
@@ -186,6 +211,11 @@ struct AccountView: View {
186211 Button("Remove", role: .destructive) {
187212 removingSSH = key
188213 }
214 Button("Label") {
215 sshLabelText = key.label ?? ""
216 labellingSSH = key
217 }
218 .tint(.gbAccent)
189219 }
190220 }
191221 Button {
@@ -308,11 +338,13 @@ private struct KeyPasteSheet: View {
308338 let scopes: [String]?
309339 let working: Bool
310340 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
312343
313344 @Environment(\.dismiss) private var dismiss
314345 @State private var text = ""
315346 @State private var scope = "full"
347 @State private var label = ""
316348
317349 var body: some View {
318350 NavigationStack {
@@ -333,6 +365,10 @@ private struct KeyPasteSheet: View {
333365 ForEach(scopes, id: \.self) { Text($0).tag($0) }
334366 }
335367 .pickerStyle(.segmented)
368 TextField("Label (optional)", text: $label)
369 .autocorrectionDisabled()
370 .textInputAutocapitalization(.never)
371 .accessibilityIdentifier("key-paste-label")
336372 }
337373 }
338374 if let errorMessage {
@@ -352,7 +388,7 @@ private struct KeyPasteSheet: View {
352388 ProgressView()
353389 } else {
354390 Button("Add") {
355 onSubmit(text, scopes != nil ? scope : nil)
391 onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "")
356392 }
357393 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
358394 .accessibilityIdentifier("key-paste-submit")
gitbayTests/AccountTests.swift +45 −1
@@ -19,7 +19,8 @@ private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
1919
2020private let keysJSON = """
2121 {"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"}\
2324 ],"exit_code":0}
2425 """
2526private let pgpJSON = """
@@ -171,6 +172,49 @@ struct AccountViewModelTests {
171172 #expect(stdin == "ssh-ed25519 AAAAC3Nza phone")
172173 }
173174
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
174218 @Test func removalsTargetTheFingerprint() async throws {
175219 let (model, stub) = try await loadedModel()
176220 for _ in 0..<2 {