Commit 507e2e3ccc
Unsigned
Layout: unified · split
gitbay/Account/AccountViewModel.swift +18 −5
| @@ -11,6 +11,8 @@ final class AccountViewModel { | ||
| 11 | 11 | let fingerprint: String |
| 12 | 12 | let algo: String |
| 13 | 13 | let scope: String |
| 14 | /// A name for the key (v1.21.0); omitted when none is set. | |
| 15 | let label: String? | |
| 14 | 16 | var id: String { fingerprint } |
| 15 | 17 | } |
| 16 | 18 | |
| @@ -125,11 +127,22 @@ final class AccountViewModel { | ||
| 125 | 127 | |
| 126 | 128 | // MARK: - SSH keys |
| 127 | 129 | |
| 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) | |
| 133 | 146 | } |
| 134 | 147 | |
| 135 | 148 | func removeSSHKey(_ key: SSHKey) async { |
gitbay/Views/Account/AccountView.swift +41 −5
| @@ -11,6 +11,8 @@ struct AccountView: View { | ||
| 11 | 11 | @State private var verifyCode = "" |
| 12 | 12 | @State private var removingEmail: AccountViewModel.EmailAddress? |
| 13 | 13 | @State private var removingSSH: AccountViewModel.SSHKey? |
| 14 | @State private var labellingSSH: AccountViewModel.SSHKey? | |
| 15 | @State private var sshLabelText = "" | |
| 14 | 16 | @State private var removingPGP: AccountViewModel.PGPKey? |
| 15 | 17 | |
| 16 | 18 | init(client: GitbayClient) { |
| @@ -87,9 +89,9 @@ struct AccountView: View { | ||
| 87 | 89 | scopes: ["full", "git"], |
| 88 | 90 | working: model.working, |
| 89 | 91 | errorMessage: model.actionError |
| 90 | ) { text, scope in | |
| 92 | ) { text, scope, label in | |
| 91 | 93 | Task { |
| 92 | await model.addSSHKey(text, scope: scope ?? "full") | |
| 94 | await model.addSSHKey(text, scope: scope ?? "full", label: label) | |
| 93 | 95 | if model.actionError == nil { addingSSH = false } |
| 94 | 96 | } |
| 95 | 97 | } |
| @@ -101,13 +103,32 @@ struct AccountView: View { | ||
| 101 | 103 | scopes: nil, |
| 102 | 104 | working: model.working, |
| 103 | 105 | errorMessage: model.actionError |
| 104 | ) { text, _ in | |
| 106 | ) { text, _, _ in | |
| 105 | 107 | Task { |
| 106 | 108 | await model.addPGPKey(text) |
| 107 | 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 | 132 | .confirmationDialog( |
| 112 | 133 | "Remove this SSH key? Anything authenticating with it loses access.", |
| 113 | 134 | isPresented: Binding( |
| @@ -171,6 +192,10 @@ struct AccountView: View { | ||
| 171 | 192 | Section { |
| 172 | 193 | ForEach(keys) { key in |
| 173 | 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 | 199 | Text(key.fingerprint) |
| 175 | 200 | .font(.gbMono(.caption)) |
| 176 | 201 | .lineLimit(1) |
| @@ -186,6 +211,11 @@ struct AccountView: View { | ||
| 186 | 211 | Button("Remove", role: .destructive) { |
| 187 | 212 | removingSSH = key |
| 188 | 213 | } |
| 214 | Button("Label") { | |
| 215 | sshLabelText = key.label ?? "" | |
| 216 | labellingSSH = key | |
| 217 | } | |
| 218 | .tint(.gbAccent) | |
| 189 | 219 | } |
| 190 | 220 | } |
| 191 | 221 | Button { |
| @@ -308,11 +338,13 @@ private struct KeyPasteSheet: View { | ||
| 308 | 338 | let scopes: [String]? |
| 309 | 339 | let working: Bool |
| 310 | 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 | 344 | @Environment(\.dismiss) private var dismiss |
| 314 | 345 | @State private var text = "" |
| 315 | 346 | @State private var scope = "full" |
| 347 | @State private var label = "" | |
| 316 | 348 | |
| 317 | 349 | var body: some View { |
| 318 | 350 | NavigationStack { |
| @@ -333,6 +365,10 @@ private struct KeyPasteSheet: View { | ||
| 333 | 365 | ForEach(scopes, id: \.self) { Text($0).tag($0) } |
| 334 | 366 | } |
| 335 | 367 | .pickerStyle(.segmented) |
| 368 | TextField("Label (optional)", text: $label) | |
| 369 | .autocorrectionDisabled() | |
| 370 | .textInputAutocapitalization(.never) | |
| 371 | .accessibilityIdentifier("key-paste-label") | |
| 336 | 372 | } |
| 337 | 373 | } |
| 338 | 374 | if let errorMessage { |
| @@ -352,7 +388,7 @@ private struct KeyPasteSheet: View { | ||
| 352 | 388 | ProgressView() |
| 353 | 389 | } else { |
| 354 | 390 | Button("Add") { |
| 355 | onSubmit(text, scopes != nil ? scope : nil) | |
| 391 | onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "") | |
| 356 | 392 | } |
| 357 | 393 | .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| 358 | 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 | |
| 20 | 20 | private let keysJSON = """ |
| 21 | 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 | 24 | ],"exit_code":0} |
| 24 | 25 | """ |
| 25 | 26 | private let pgpJSON = """ |
| @@ -171,6 +172,49 @@ struct AccountViewModelTests { | ||
| 171 | 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 | 218 | @Test func removalsTargetTheFingerprint() async throws { |
| 175 | 219 | let (model, stub) = try await loadedModel() |
| 176 | 220 | for _ in 0..<2 { |