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 {
1414 /// A name for the key (v1.21.0); omitted when none is set.
1515 let label: String?
1616 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 }
1722 }
1823
1924 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
gitbay/Views/Account/AccountView.swift +15 −8
@@ -11,6 +11,7 @@ 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 sshConfirmText = ""
1415 @State private var labellingSSH: AccountViewModel.SSHKey?
1516 @State private var sshLabelText = ""
1617 @State private var removingPGP: AccountViewModel.PGPKey?
@@ -129,20 +130,25 @@ struct AccountView: View {
129130 } message: { _ in
130131 Text("An empty label clears it.")
131132 }
132 .confirmationDialog(
133 "Remove this SSH key? Anything authenticating with it loses access.",
133 .alert(
134 "Remove this SSH key?",
134135 isPresented: Binding(
135136 get: { removingSSH != nil },
136137 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)
139144 Button("Remove", role: .destructive) {
140 if let key = removingSSH {
141 Task { await model.removeSSHKey(key) }
142 }
145 Task { await model.removeSSHKey(key) }
143146 removingSSH = nil
144147 }
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.")
146152 }
147153 .confirmationDialog(
148154 "Remove this PGP key? Commits it signed become unverifiable.",
@@ -209,6 +215,7 @@ struct AccountView: View {
209215 }
210216 .swipeActions {
211217 Button("Remove", role: .destructive) {
218 sshConfirmText = ""
212219 removingSSH = key
213220 }
214221 Button("Label") {
gitbay/Views/Repos/RepoSettingsView.swift +8 −2
@@ -12,6 +12,7 @@ struct RepoSettingsView: View {
1212 @State private var newTagGlob = ""
1313 @State private var renaming = false
1414 @State private var newName = ""
15 @State private var renameConfirm = ""
1516 /// Set once a rename succeeds: this screen's repository is gone and
1617 /// the renamed one is a value push away.
1718 @State private var renamedTo: String?
@@ -99,6 +100,9 @@ struct RepoSettingsView: View {
99100 TextField("New name", text: $newName)
100101 .autocorrectionDisabled()
101102 .textInputAutocapitalization(.never)
103 TextField("type \(model.repoPath) to confirm", text: $renameConfirm)
104 .autocorrectionDisabled()
105 .textInputAutocapitalization(.never)
102106 Button("Rename") {
103107 Task {
104108 if let path = await model.rename(to: newName) {
@@ -106,10 +110,11 @@ struct RepoSettingsView: View {
106110 }
107111 }
108112 }
109 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
113 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty
114 || renameConfirm != model.repoPath)
110115 Button("Cancel", role: .cancel) {}
111116 } message: {
112 Text("Clone URLs change.")
117 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
113118 }
114119 .confirmationDialog(
115120 "Remove topic \(removingTopic ?? "")?",
@@ -448,6 +453,7 @@ struct RepoSettingsView: View {
448453 Section {
449454 Button {
450455 newName = String(model.repoPath.split(separator: "/").last ?? "")
456 renameConfirm = ""
451457 renaming = true
452458 } label: {
453459 Label("Rename repository", systemImage: "pencil")
gitbayTests/AccountTests.swift +8
@@ -179,6 +179,14 @@ struct AccountViewModelTests {
179179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac")
180180 }
181181
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
182190 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
183191 let (model, stub) = try await loadedModel()
184192 for _ in 0..<2 {
gitbayUITests/LiveSmokeUITests.swift +26 −1
@@ -663,6 +663,7 @@ extension LiveSmokeUITests {
663663 // Each rename pushes the renamed repository; the round-trip ends
664664 // on the scratch repo again, several screens deep.
665665 var firstRename = true
666 var currentPath = Self.scratchRepo
666667 let rename: (String, String) -> Void = { newName, expectedPath in
667668 if firstRename {
668669 firstRename = false // already on the settings screen
@@ -678,7 +679,14 @@ extension LiveSmokeUITests {
678679 field.tap()
679680 // Prefilled with the current name; clear it before typing.
680681 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()
682690 // The settings screen offers the renamed repository at the
683691 // top; the list is scrolled to its end, so go back up first.
684692 let renamedLink = self.app.descendants(matching: .any)
@@ -691,6 +699,7 @@ extension LiveSmokeUITests {
691699 // splits the path into the owner link and the rest.
692700 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
693701 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
702 currentPath = expectedPath
694703 }
695704 rename("ui-smoke-2", Self.scratchRepo + "-2")
696705 rename("ui-smoke", Self.scratchRepo)
@@ -1715,6 +1724,22 @@ extension LiveSmokeUITests {
17151724 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
17161725 "original label not back")
17171726 }
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")
17181743 }
17191744
17201745 /// Org labels and milestones from the org screen: a label is created