Commit 2acd56957d

2acd56957db3d4abd89ae6e178fd92f3c3229ce4

parent: 458e188c63

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:29 UTC

Access grants in repo settings (!73)

repo access list, grant and revoke as an Access section in repo settings: a role chip menu regrants, the add row grants, and a swipe revokes after a confirmation. The grants read keeps the nil-plus-error shape deps uses. The live suite grants and revokes apple-review on the scratch repo.

Layout: unified · split

gitbay/Repos/RepoSettings.swift +7
@@ -26,3 +26,10 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
26 26
27 var branches: [String] { protectedBranches ?? [] } 27 var branches: [String] { protectedBranches ?? [] }
28} 28}
29
30/// One row of `repo access list`: a user granted read, write or admin.
31nonisolated 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 private(set) var deps: DepsStatus? 25 private(set) var deps: DepsStatus?
26 private(set) var depsError: String? 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 private let client: GitbayClient 33 private let client: GitbayClient
29 let repoPath: String 34 let repoPath: String
30 35
@@ -111,6 +116,30 @@ final class RepoSettingsViewModel {
111 await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"]) 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 func addTopic(_ topic: String) async { 143 func addTopic(_ topic: String) async {
115 await perform(["repo", "topics", "add", repoPath, topic]) 144 await perform(["repo", "topics", "add", repoPath, topic])
116 } 145 }
gitbay/Views/Repos/RepoSettingsView.swift +79
@@ -11,6 +11,9 @@ struct RepoSettingsView: View {
11 @State private var newBranch = "" 11 @State private var newBranch = ""
12 @State private var loadedOnce = false 12 @State private var loadedOnce = false
13 @State private var removingTopic: String? 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 init(client: GitbayClient, repo: String) { 18 init(client: GitbayClient, repo: String) {
16 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo)) 19 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
@@ -30,6 +33,7 @@ struct RepoSettingsView: View {
30 branchesSection(loaded) 33 branchesSection(loaded)
31 mergeRulesSection(loaded) 34 mergeRulesSection(loaded)
32 daemonSection(loaded) 35 daemonSection(loaded)
36 accessSection
33 depsSection() 37 depsSection()
34 } 38 }
35 } 39 }
@@ -45,9 +49,26 @@ struct RepoSettingsView: View {
45 } 49 }
46 } 50 }
47 .task { await model.loadDeps() } 51 .task { await model.loadDeps() }
52 .task { await model.loadGrants() }
48 .refreshable { 53 .refreshable {
49 await model.load() 54 await model.load()
50 await model.loadDeps() 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 .confirmationDialog( 73 .confirmationDialog(
53 "Remove topic \(removingTopic ?? "")?", 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 private func depsSection() -> some View { 312 private func depsSection() -> some View {
234 Section { 313 Section {
235 if let deps = model.deps { 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 @Test func aDeniedKnobSurfacesAndKeepsTheScreen() async throws { 160 @Test func aDeniedKnobSurfacesAndKeepsTheScreen() async throws {
115 let (model, stub) = try await loadedModel() 161 let (model, stub) = try await loadedModel()
116 stub.enqueue(.init(status: 403, json: 162 stub.enqueue(.init(status: 403, json:
gitbayUITests/LiveSmokeUITests.swift +21
@@ -423,6 +423,27 @@ extension LiveSmokeUITests {
423 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch 423 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
424 .waitForExistence(timeout: 15), "created repo not in the list") 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 // --- pin / unpin round-trip on krz/gitbay-ios --- 447 // --- pin / unpin round-trip on krz/gitbay-ios ---
427 // Reuse the open search to get there. 448 // Reuse the open search to get there.
428 let clear = search.buttons.firstMatch 449 let clear = search.buttons.firstMatch