Commit 76cfe39723

76cfe39723372c717d18ea3c730c28e66c0798a3

parent: f302d0123a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-30 02:15 UTC

Typed-name confirmation for SSH key removal and repository rename

Key removal asks for the fingerprint's first eight characters after
SHA256:, as the web does; rename asks for owner/name, as the web's
transfer and delete do. The live suite types both.

Layout: unified · split

gitbay/Account/AccountViewModel.swift +5
@@ -14,6 +14,11 @@ final class AccountViewModel {
14 /// A name for the key (v1.21.0); omitted when none is set. 14 /// A name for the key (v1.21.0); omitted when none is set.
15 let label: String? 15 let label: String?
16 var id: String { fingerprint } 16 var id: String { fingerprint }
17 /// What removal asks to be typed, as the web does: the first
18 /// eight characters of the fingerprint after `SHA256:`.
19 var confirmation: String {
20 String(fingerprint.replacingOccurrences(of: "SHA256:", with: "").prefix(8))
21 }
17 } 22 }
18 23
19 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable { 24 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
gitbay/Views/Account/AccountView.swift +15 −8
@@ -11,6 +11,7 @@ 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 sshConfirmText = ""
14 @State private var labellingSSH: AccountViewModel.SSHKey? 15 @State private var labellingSSH: AccountViewModel.SSHKey?
15 @State private var sshLabelText = "" 16 @State private var sshLabelText = ""
16 @State private var removingPGP: AccountViewModel.PGPKey? 17 @State private var removingPGP: AccountViewModel.PGPKey?
@@ -129,20 +130,25 @@ struct AccountView: View {
129 } message: { _ in 130 } message: { _ in
130 Text("An empty label clears it.") 131 Text("An empty label clears it.")
131 } 132 }
132 .confirmationDialog( 133 .alert(
133 "Remove this SSH key? Anything authenticating with it loses access.", 134 "Remove this SSH key?",
134 isPresented: Binding( 135 isPresented: Binding(
135 get: { removingSSH != nil }, 136 get: { removingSSH != nil },
136 set: { if !$0 { removingSSH = nil } } 137 set: { if !$0 { removingSSH = nil } }
137 ) 138 ),
138 ) { 139 presenting: removingSSH
140 ) { key in
141 TextField("type \(key.confirmation) to confirm", text: $sshConfirmText)
142 .autocorrectionDisabled()
143 .textInputAutocapitalization(.never)
139 Button("Remove", role: .destructive) { 144 Button("Remove", role: .destructive) {
140 if let key = removingSSH { 145 Task { await model.removeSSHKey(key) }
141 Task { await model.removeSSHKey(key) }
142 }
143 removingSSH = nil 146 removingSSH = nil
144 } 147 }
145 Button("Cancel", role: .cancel) {} 148 .disabled(sshConfirmText != key.confirmation)
149 Button("Cancel", role: .cancel) { removingSSH = nil }
150 } message: { key in
151 Text("Anything authenticating with it loses access. Type \(key.confirmation) to confirm.")
146 } 152 }
147 .confirmationDialog( 153 .confirmationDialog(
148 "Remove this PGP key? Commits it signed become unverifiable.", 154 "Remove this PGP key? Commits it signed become unverifiable.",
@@ -209,6 +215,7 @@ struct AccountView: View {
209 } 215 }
210 .swipeActions { 216 .swipeActions {
211 Button("Remove", role: .destructive) { 217 Button("Remove", role: .destructive) {
218 sshConfirmText = ""
212 removingSSH = key 219 removingSSH = key
213 } 220 }
214 Button("Label") { 221 Button("Label") {
gitbay/Views/Repos/RepoSettingsView.swift +8 −2
@@ -12,6 +12,7 @@ struct RepoSettingsView: View {
12 @State private var newTagGlob = "" 12 @State private var newTagGlob = ""
13 @State private var renaming = false 13 @State private var renaming = false
14 @State private var newName = "" 14 @State private var newName = ""
15 @State private var renameConfirm = ""
15 /// Set once a rename succeeds: this screen's repository is gone and 16 /// Set once a rename succeeds: this screen's repository is gone and
16 /// the renamed one is a value push away. 17 /// the renamed one is a value push away.
17 @State private var renamedTo: String? 18 @State private var renamedTo: String?
@@ -99,6 +100,9 @@ struct RepoSettingsView: View {
99 TextField("New name", text: $newName) 100 TextField("New name", text: $newName)
100 .autocorrectionDisabled() 101 .autocorrectionDisabled()
101 .textInputAutocapitalization(.never) 102 .textInputAutocapitalization(.never)
103 TextField("type \(model.repoPath) to confirm", text: $renameConfirm)
104 .autocorrectionDisabled()
105 .textInputAutocapitalization(.never)
102 Button("Rename") { 106 Button("Rename") {
103 Task { 107 Task {
104 if let path = await model.rename(to: newName) { 108 if let path = await model.rename(to: newName) {
@@ -106,10 +110,11 @@ struct RepoSettingsView: View {
106 } 110 }
107 } 111 }
108 } 112 }
109 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) 113 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty
114 || renameConfirm != model.repoPath)
110 Button("Cancel", role: .cancel) {} 115 Button("Cancel", role: .cancel) {}
111 } message: { 116 } message: {
112 Text("Clone URLs change.") 117 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
113 } 118 }
114 .confirmationDialog( 119 .confirmationDialog(
115 "Remove topic \(removingTopic ?? "")?", 120 "Remove topic \(removingTopic ?? "")?",
@@ -448,6 +453,7 @@ struct RepoSettingsView: View {
448 Section { 453 Section {
449 Button { 454 Button {
450 newName = String(model.repoPath.split(separator: "/").last ?? "") 455 newName = String(model.repoPath.split(separator: "/").last ?? "")
456 renameConfirm = ""
451 renaming = true 457 renaming = true
452 } label: { 458 } label: {
453 Label("Rename repository", systemImage: "pencil") 459 Label("Rename repository", systemImage: "pencil")
gitbayTests/AccountTests.swift +8
@@ -179,6 +179,14 @@ struct AccountViewModelTests {
179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac") 179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac")
180 } 180 }
181 181
182 /// The web's typed confirmation for removing a key: the first eight
183 /// characters of the fingerprint after `SHA256:`.
184 @Test func sshKeyConfirmationIsTheFingerprintPrefix() async throws {
185 let (model, _) = try await loadedModel()
186 let key = try #require(model.state.value?.sshKeys.first)
187 #expect(key.confirmation == "15jrWGl3")
188 }
189
182 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws { 190 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
183 let (model, stub) = try await loadedModel() 191 let (model, stub) = try await loadedModel()
184 for _ in 0..<2 { 192 for _ in 0..<2 {
gitbayUITests/LiveSmokeUITests.swift +26 −1
@@ -663,6 +663,7 @@ extension LiveSmokeUITests {
663 // Each rename pushes the renamed repository; the round-trip ends 663 // Each rename pushes the renamed repository; the round-trip ends
664 // on the scratch repo again, several screens deep. 664 // on the scratch repo again, several screens deep.
665 var firstRename = true 665 var firstRename = true
666 var currentPath = Self.scratchRepo
666 let rename: (String, String) -> Void = { newName, expectedPath in 667 let rename: (String, String) -> Void = { newName, expectedPath in
667 if firstRename { 668 if firstRename {
668 firstRename = false // already on the settings screen 669 firstRename = false // already on the settings screen
@@ -678,7 +679,14 @@ extension LiveSmokeUITests {
678 field.tap() 679 field.tap()
679 // Prefilled with the current name; clear it before typing. 680 // Prefilled with the current name; clear it before typing.
680 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName) 681 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName)
681 self.app.buttons["Rename"].firstMatch.tap() 682 // Rename stays disabled until the current path is typed.
683 let renameAction = self.app.alerts.buttons["Rename"].firstMatch
684 XCTAssertFalse(renameAction.isEnabled, "rename enabled before the path was typed")
685 let confirm = self.app.textFields["type \(currentPath) to confirm"].firstMatch
686 XCTAssertTrue(confirm.exists, "rename confirmation field missing")
687 confirm.tap()
688 confirm.typeText(currentPath)
689 renameAction.tap()
682 // The settings screen offers the renamed repository at the 690 // The settings screen offers the renamed repository at the
683 // top; the list is scrolled to its end, so go back up first. 691 // top; the list is scrolled to its end, so go back up first.
684 let renamedLink = self.app.descendants(matching: .any) 692 let renamedLink = self.app.descendants(matching: .any)
@@ -691,6 +699,7 @@ extension LiveSmokeUITests {
691 // splits the path into the owner link and the rest. 699 // splits the path into the owner link and the rest.
692 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch 700 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
693 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)") 701 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
702 currentPath = expectedPath
694 } 703 }
695 rename("ui-smoke-2", Self.scratchRepo + "-2") 704 rename("ui-smoke-2", Self.scratchRepo + "-2")
696 rename("ui-smoke", Self.scratchRepo) 705 rename("ui-smoke", Self.scratchRepo)
@@ -1715,6 +1724,22 @@ extension LiveSmokeUITests {
1715 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10), 1724 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
1716 "original label not back") 1725 "original label not back")
1717 } 1726 }
1727
1728 // Removal asks for the fingerprint's first eight characters and
1729 // stays disabled until they are typed. Cancelled: the key stays.
1730 fingerprint.swipeLeft()
1731 let remove = app.buttons["Remove"].firstMatch
1732 XCTAssertTrue(remove.waitForExistence(timeout: 5), "Remove swipe action missing")
1733 remove.tap()
1734 let confirm = app.alerts.textFields.firstMatch
1735 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "remove alert missing")
1736 let removeAction = app.alerts.buttons["Remove"].firstMatch
1737 XCTAssertFalse(removeAction.isEnabled, "remove enabled before confirmation")
1738 confirm.tap()
1739 confirm.typeText(String(fp.dropFirst("SHA256:".count).prefix(8)))
1740 XCTAssertTrue(removeAction.isEnabled, "remove not enabled by the typed confirmation")
1741 app.alerts.buttons["Cancel"].firstMatch.tap()
1742 XCTAssertTrue(fingerprint.waitForExistence(timeout: 5), "key gone after cancel")
1718 } 1743 }
1719 1744
1720 /// Org labels and milestones from the org screen: a label is created 1745 /// Org labels and milestones from the org screen: a label is created