Commit 76cfe39723
Verified · cmc
Layout: unified · split
gitbay/Account/AccountViewModel.swift +5
| @@ -14,6 +14,11 @@ final class AccountViewModel { | ||
| 14 | 14 | /// A name for the key (v1.21.0); omitted when none is set. |
| 15 | 15 | let label: String? |
| 16 | 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 | 24 | nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable { |
gitbay/Views/Account/AccountView.swift +15 −8
| @@ -11,6 +11,7 @@ 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 sshConfirmText = "" | |
| 14 | 15 | @State private var labellingSSH: AccountViewModel.SSHKey? |
| 15 | 16 | @State private var sshLabelText = "" |
| 16 | 17 | @State private var removingPGP: AccountViewModel.PGPKey? |
| @@ -129,20 +130,25 @@ struct AccountView: View { | ||
| 129 | 130 | } message: { _ in |
| 130 | 131 | Text("An empty label clears it.") |
| 131 | 132 | } |
| 132 | .confirmationDialog( | |
| 133 | "Remove this SSH key? Anything authenticating with it loses access.", | |
| 133 | .alert( | |
| 134 | "Remove this SSH key?", | |
| 134 | 135 | isPresented: Binding( |
| 135 | 136 | get: { removingSSH != nil }, |
| 136 | 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 | 144 | Button("Remove", role: .destructive) { |
| 140 | if let key = removingSSH { | |
| 141 | Task { await model.removeSSHKey(key) } | |
| 142 | } | |
| 145 | Task { await model.removeSSHKey(key) } | |
| 143 | 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 | 153 | .confirmationDialog( |
| 148 | 154 | "Remove this PGP key? Commits it signed become unverifiable.", |
| @@ -209,6 +215,7 @@ struct AccountView: View { | ||
| 209 | 215 | } |
| 210 | 216 | .swipeActions { |
| 211 | 217 | Button("Remove", role: .destructive) { |
| 218 | sshConfirmText = "" | |
| 212 | 219 | removingSSH = key |
| 213 | 220 | } |
| 214 | 221 | Button("Label") { |
gitbay/Views/Repos/RepoSettingsView.swift +8 −2
| @@ -12,6 +12,7 @@ struct RepoSettingsView: View { | ||
| 12 | 12 | @State private var newTagGlob = "" |
| 13 | 13 | @State private var renaming = false |
| 14 | 14 | @State private var newName = "" |
| 15 | @State private var renameConfirm = "" | |
| 15 | 16 | /// Set once a rename succeeds: this screen's repository is gone and |
| 16 | 17 | /// the renamed one is a value push away. |
| 17 | 18 | @State private var renamedTo: String? |
| @@ -99,6 +100,9 @@ struct RepoSettingsView: View { | ||
| 99 | 100 | TextField("New name", text: $newName) |
| 100 | 101 | .autocorrectionDisabled() |
| 101 | 102 | .textInputAutocapitalization(.never) |
| 103 | TextField("type \(model.repoPath) to confirm", text: $renameConfirm) | |
| 104 | .autocorrectionDisabled() | |
| 105 | .textInputAutocapitalization(.never) | |
| 102 | 106 | Button("Rename") { |
| 103 | 107 | Task { |
| 104 | 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 | 115 | Button("Cancel", role: .cancel) {} |
| 111 | 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 | 119 | .confirmationDialog( |
| 115 | 120 | "Remove topic \(removingTopic ?? "")?", |
| @@ -448,6 +453,7 @@ struct RepoSettingsView: View { | ||
| 448 | 453 | Section { |
| 449 | 454 | Button { |
| 450 | 455 | newName = String(model.repoPath.split(separator: "/").last ?? "") |
| 456 | renameConfirm = "" | |
| 451 | 457 | renaming = true |
| 452 | 458 | } label: { |
| 453 | 459 | Label("Rename repository", systemImage: "pencil") |
gitbayTests/AccountTests.swift +8
| @@ -179,6 +179,14 @@ struct AccountViewModelTests { | ||
| 179 | 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 | 190 | @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws { |
| 183 | 191 | let (model, stub) = try await loadedModel() |
| 184 | 192 | for _ in 0..<2 { |
gitbayUITests/LiveSmokeUITests.swift +26 −1
| @@ -663,6 +663,7 @@ extension LiveSmokeUITests { | ||
| 663 | 663 | // Each rename pushes the renamed repository; the round-trip ends |
| 664 | 664 | // on the scratch repo again, several screens deep. |
| 665 | 665 | var firstRename = true |
| 666 | var currentPath = Self.scratchRepo | |
| 666 | 667 | let rename: (String, String) -> Void = { newName, expectedPath in |
| 667 | 668 | if firstRename { |
| 668 | 669 | firstRename = false // already on the settings screen |
| @@ -678,7 +679,14 @@ extension LiveSmokeUITests { | ||
| 678 | 679 | field.tap() |
| 679 | 680 | // Prefilled with the current name; clear it before typing. |
| 680 | 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 | 690 | // The settings screen offers the renamed repository at the |
| 683 | 691 | // top; the list is scrolled to its end, so go back up first. |
| 684 | 692 | let renamedLink = self.app.descendants(matching: .any) |
| @@ -691,6 +699,7 @@ extension LiveSmokeUITests { | ||
| 691 | 699 | // splits the path into the owner link and the rest. |
| 692 | 700 | XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch |
| 693 | 701 | .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)") |
| 702 | currentPath = expectedPath | |
| 694 | 703 | } |
| 695 | 704 | rename("ui-smoke-2", Self.scratchRepo + "-2") |
| 696 | 705 | rename("ui-smoke", Self.scratchRepo) |
| @@ -1715,6 +1724,22 @@ extension LiveSmokeUITests { | ||
| 1715 | 1724 | XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10), |
| 1716 | 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 | 1745 | /// Org labels and milestones from the org screen: a label is created |