Commit 2acd56957d
Unsigned
Layout: unified · split
gitbay/Repos/RepoSettings.swift +7
| @@ -26,3 +26,10 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | ||
| 26 | 26 | |
| 27 | 27 | var branches: [String] { protectedBranches ?? [] } |
| 28 | 28 | } |
| 29 | ||
| 30 | /// One row of `repo access list`: a user granted read, write or admin. | |
| 31 | nonisolated struct AccessGrant: Decodable, Sendable, Hashable, Identifiable { | |
| 32 | let user: String | |
| 33 | let role: String | |
| 34 | var id: String { user } | |
| 35 | } | |
gitbay/Repos/RepoSettingsViewModel.swift +29
| @@ -25,6 +25,11 @@ final class RepoSettingsViewModel { | ||
| 25 | 25 | private(set) var deps: DepsStatus? |
| 26 | 26 | private(set) var depsError: String? |
| 27 | 27 | |
| 28 | /// `repo access list`, with the same nil-plus-error shape as `deps`: | |
| 29 | /// "not loaded" and "could not ask" must not read as "nobody granted". | |
| 30 | private(set) var grants: [AccessGrant]? | |
| 31 | private(set) var grantsError: String? | |
| 32 | ||
| 28 | 33 | private let client: GitbayClient |
| 29 | 34 | let repoPath: String |
| 30 | 35 | |
| @@ -111,6 +116,30 @@ final class RepoSettingsViewModel { | ||
| 111 | 116 | await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"]) |
| 112 | 117 | } |
| 113 | 118 | |
| 119 | func loadGrants() async { | |
| 120 | grantsError = nil | |
| 121 | grants = nil | |
| 122 | do { | |
| 123 | grants = try await client.readList(["repo", "access", "list", repoPath], of: AccessGrant.self) | |
| 124 | } catch let error as GitbayError { | |
| 125 | grantsError = error.userFacingMessage | |
| 126 | } catch { | |
| 127 | grantsError = GitbayError.transport(error).userFacingMessage | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | /// `repo access grant <repo> <user> read|write|admin` — granting again | |
| 132 | /// changes an existing user's role. | |
| 133 | func grant(user: String, role: String) async { | |
| 134 | let user = user.trimmingCharacters(in: .whitespaces) | |
| 135 | guard !user.isEmpty else { return } | |
| 136 | await perform(["repo", "access", "grant", repoPath, user, role], reload: loadGrants) | |
| 137 | } | |
| 138 | ||
| 139 | func revoke(user: String) async { | |
| 140 | await perform(["repo", "access", "revoke", repoPath, user], reload: loadGrants) | |
| 141 | } | |
| 142 | ||
| 114 | 143 | func addTopic(_ topic: String) async { |
| 115 | 144 | await perform(["repo", "topics", "add", repoPath, topic]) |
| 116 | 145 | } |
gitbay/Views/Repos/RepoSettingsView.swift +79
| @@ -11,6 +11,9 @@ struct RepoSettingsView: View { | ||
| 11 | 11 | @State private var newBranch = "" |
| 12 | 12 | @State private var loadedOnce = false |
| 13 | 13 | @State private var removingTopic: String? |
| 14 | @State private var newGrantUser = "" | |
| 15 | @State private var newGrantRole = "read" | |
| 16 | @State private var revokingGrant: AccessGrant? | |
| 14 | 17 | |
| 15 | 18 | init(client: GitbayClient, repo: String) { |
| 16 | 19 | _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo)) |
| @@ -30,6 +33,7 @@ struct RepoSettingsView: View { | ||
| 30 | 33 | branchesSection(loaded) |
| 31 | 34 | mergeRulesSection(loaded) |
| 32 | 35 | daemonSection(loaded) |
| 36 | accessSection | |
| 33 | 37 | depsSection() |
| 34 | 38 | } |
| 35 | 39 | } |
| @@ -45,9 +49,26 @@ struct RepoSettingsView: View { | ||
| 45 | 49 | } |
| 46 | 50 | } |
| 47 | 51 | .task { await model.loadDeps() } |
| 52 | .task { await model.loadGrants() } | |
| 48 | 53 | .refreshable { |
| 49 | 54 | await model.load() |
| 50 | 55 | await model.loadDeps() |
| 56 | await model.loadGrants() | |
| 57 | } | |
| 58 | .confirmationDialog( | |
| 59 | "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?", | |
| 60 | isPresented: Binding( | |
| 61 | get: { revokingGrant != nil }, | |
| 62 | set: { if !$0 { revokingGrant = nil } } | |
| 63 | ) | |
| 64 | ) { | |
| 65 | Button("Revoke", role: .destructive) { | |
| 66 | if let grant = revokingGrant { | |
| 67 | Task { await model.revoke(user: grant.user) } | |
| 68 | } | |
| 69 | revokingGrant = nil | |
| 70 | } | |
| 71 | Button("Cancel", role: .cancel) {} | |
| 51 | 72 | } |
| 52 | 73 | .confirmationDialog( |
| 53 | 74 | "Remove topic \(removingTopic ?? "")?", |
| @@ -230,6 +251,64 @@ struct RepoSettingsView: View { | ||
| 230 | 251 | } |
| 231 | 252 | } |
| 232 | 253 | |
| 254 | /// `repo access list`, grant and revoke. Owners and org members are | |
| 255 | /// not grants and do not appear here. | |
| 256 | private var accessSection: some View { | |
| 257 | Section { | |
| 258 | if let error = model.grantsError { | |
| 259 | GBNotice(error, .gbWarn) | |
| 260 | } | |
| 261 | ForEach(model.grants ?? []) { grant in | |
| 262 | HStack { | |
| 263 | NavigationLink(value: RepoRoute.profile(grant.user)) { | |
| 264 | Text(grant.user) | |
| 265 | .font(.gbSans(.subheadline)) | |
| 266 | } | |
| 267 | Spacer() | |
| 268 | Menu { | |
| 269 | ForEach(["read", "write", "admin"], id: \.self) { role in | |
| 270 | Button(role) { Task { await model.grant(user: grant.user, role: role) } } | |
| 271 | } | |
| 272 | } label: { | |
| 273 | GBChip(grant.role, .secondary) | |
| 274 | } | |
| 275 | .disabled(model.working) | |
| 276 | } | |
| 277 | .swipeActions { | |
| 278 | Button("Revoke", role: .destructive) { | |
| 279 | revokingGrant = grant | |
| 280 | } | |
| 281 | } | |
| 282 | } | |
| 283 | HStack { | |
| 284 | TextField("Grant user", text: $newGrantUser) | |
| 285 | .autocorrectionDisabled() | |
| 286 | .textInputAutocapitalization(.never) | |
| 287 | .accessibilityIdentifier("settings-grant-user") | |
| 288 | Picker("", selection: $newGrantRole) { | |
| 289 | Text("read").tag("read") | |
| 290 | Text("write").tag("write") | |
| 291 | Text("admin").tag("admin") | |
| 292 | } | |
| 293 | .labelsHidden() | |
| 294 | .fixedSize() | |
| 295 | Button { | |
| 296 | let user = newGrantUser | |
| 297 | newGrantUser = "" | |
| 298 | Task { await model.grant(user: user, role: newGrantRole) } | |
| 299 | } label: { | |
| 300 | Image(systemName: "plus.circle.fill") | |
| 301 | } | |
| 302 | .disabled(newGrantUser.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 303 | .accessibilityIdentifier("settings-grant-submit") | |
| 304 | } | |
| 305 | } header: { | |
| 306 | Text("Access") | |
| 307 | } footer: { | |
| 308 | Text("Direct grants only. Ownership and org membership carry their own access.") | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 233 | 312 | private func depsSection() -> some View { |
| 234 | 313 | Section { |
| 235 | 314 | if let deps = model.deps { |
gitbayTests/RepoManagementTests.swift +46
| @@ -111,6 +111,52 @@ struct RepoSettingsViewModelTests { | ||
| 111 | 111 | ]) |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | @Test func accessGrantsLoadSeparately() async throws { | |
| 115 | let (model, stub) = try await loadedModel() | |
| 116 | stub.enqueue(.init(status: 200, json: """ | |
| 117 | {"protocol_version":1,"data":[{"user":"alice","role":"write"},\ | |
| 118 | {"user":"bob","role":"read"}],"exit_code":0} | |
| 119 | """, match: "argv=access")) | |
| 120 | ||
| 121 | await model.loadGrants() | |
| 122 | ||
| 123 | #expect(model.grants?.map(\.user) == ["alice", "bob"]) | |
| 124 | #expect(model.grants?.first?.role == "write") | |
| 125 | #expect(model.grantsError == nil) | |
| 126 | let read = try #require(stub.seen.last) | |
| 127 | #expect(read.url.query() == "argv=repo&argv=access&argv=list&argv=krz/gitbay") | |
| 128 | } | |
| 129 | ||
| 130 | @Test func aFailedGrantsReadIsAnErrorNotAnEmptyList() async throws { | |
| 131 | let (model, stub) = try await loadedModel() | |
| 132 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "argv=access")) | |
| 133 | ||
| 134 | await model.loadGrants() | |
| 135 | ||
| 136 | #expect(model.grants == nil) | |
| 137 | #expect(model.grantsError == "admin access required") | |
| 138 | } | |
| 139 | ||
| 140 | @Test func grantAndRevokeSendTheirCommandsThenReloadGrants() async throws { | |
| 141 | let (model, stub) = try await loadedModel() | |
| 142 | for _ in 0..<2 { | |
| 143 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 144 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#, match: "argv=access")) | |
| 145 | } | |
| 146 | ||
| 147 | await model.grant(user: " alice ", role: "write") | |
| 148 | await model.revoke(user: "alice") | |
| 149 | ||
| 150 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 151 | #expect(writes == [ | |
| 152 | ["repo", "access", "grant", "krz/gitbay", "alice", "write"], | |
| 153 | ["repo", "access", "revoke", "krz/gitbay", "alice"], | |
| 154 | ]) | |
| 155 | let grantReads = stub.seen.filter { $0.url.query()?.contains("argv=access&argv=list") == true } | |
| 156 | #expect(grantReads.count == 2) | |
| 157 | #expect(model.grants?.isEmpty == true) | |
| 158 | } | |
| 159 | ||
| 114 | 160 | @Test func aDeniedKnobSurfacesAndKeepsTheScreen() async throws { |
| 115 | 161 | let (model, stub) = try await loadedModel() |
| 116 | 162 | stub.enqueue(.init(status: 403, json: |
gitbayUITests/LiveSmokeUITests.swift +21
| @@ -423,6 +423,27 @@ extension LiveSmokeUITests { | ||
| 423 | 423 | XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch |
| 424 | 424 | .waitForExistence(timeout: 15), "created repo not in the list") |
| 425 | 425 | |
| 426 | // --- access: grant a user on the scratch repo, then revoke --- | |
| 427 | app.staticTexts["cmc/ui-smoke"].firstMatch.tap() | |
| 428 | XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) | |
| 429 | app.staticTexts["Settings"].firstMatch.tap() | |
| 430 | let grantUser = app.descendants(matching: .any) | |
| 431 | .matching(identifier: "settings-grant-user").firstMatch | |
| 432 | XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable") | |
| 433 | focusAndType(grantUser, "apple-review") | |
| 434 | app.descendants(matching: .any).matching(identifier: "settings-grant-submit") | |
| 435 | .firstMatch.tap() | |
| 436 | let grantRow = app.staticTexts["apple-review"].firstMatch | |
| 437 | XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed") | |
| 438 | grantRow.swipeLeft() | |
| 439 | app.buttons["Revoke"].firstMatch.tap() | |
| 440 | let confirmGrantRevoke = app.buttons["Revoke"].firstMatch | |
| 441 | XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") | |
| 442 | confirmGrantRevoke.tap() | |
| 443 | XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") | |
| 444 | back() // settings -> repo | |
| 445 | back() // repo -> list | |
| 446 | ||
| 426 | 447 | // --- pin / unpin round-trip on krz/gitbay-ios --- |
| 427 | 448 | // Reuse the open search to get there. |
| 428 | 449 | let clear = search.buttons.firstMatch |