Commit d2860af98a

d2860af98aa72c64374b7ef3b50f29aa0f9288ab

parent: 4ba5e2812c

Unsigned

cmc <hello@cleberg.net> · 2026-09-08 03:13 UTC

Access section shows where access comes from (!88)

repo access list reports effective access since v1.16.0 (krz/gitbay#200) with a source per row: owner, direct, org admin, org member, or team. The row shows a source chip for anything but a direct grant, and only a direct grant offers revoke. An older server's rows decode with no source and stay revocable.

Layout: unified · split

gitbay/Repos/RepoSettings.swift +6 −1
@@ -27,9 +27,14 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
27 var branches: [String] { protectedBranches ?? [] } 27 var branches: [String] { protectedBranches ?? [] }
28} 28}
29 29
30/// One row of `repo access list`: a user granted read, write or admin. 30/// One row of `repo access list`: an account and its effective role.
31/// Since v1.16.0 the row says where the access comes from — owner,
32/// direct, org admin, org member, or team; only a direct grant can be
33/// revoked here. An older server lists direct grants and says nothing.
31nonisolated struct AccessGrant: Decodable, Sendable, Hashable, Identifiable { 34nonisolated struct AccessGrant: Decodable, Sendable, Hashable, Identifiable {
32 let user: String 35 let user: String
33 let role: String 36 let role: String
37 let source: String?
34 var id: String { user } 38 var id: String { user }
39 var isDirect: Bool { source == nil || source == "direct" }
35} 40}
gitbay/Views/Repos/RepoSettingsView.swift +8 −3
@@ -267,6 +267,9 @@ struct RepoSettingsView: View {
267 .font(.gbSans(.subheadline)) 267 .font(.gbSans(.subheadline))
268 } 268 }
269 Spacer() 269 Spacer()
270 if let source = grant.source, source != "direct" {
271 GBChip(source, .secondary)
272 }
270 Menu { 273 Menu {
271 ForEach(["read", "write", "admin"], id: \.self) { role in 274 ForEach(["read", "write", "admin"], id: \.self) { role in
272 Button(role) { Task { await model.grant(user: grant.user, role: role) } } 275 Button(role) { Task { await model.grant(user: grant.user, role: role) } }
@@ -277,8 +280,10 @@ struct RepoSettingsView: View {
277 .disabled(model.working) 280 .disabled(model.working)
278 } 281 }
279 .swipeActions { 282 .swipeActions {
280 Button("Revoke", role: .destructive) { 283 if grant.isDirect {
281 revokingGrant = grant 284 Button("Revoke", role: .destructive) {
285 revokingGrant = grant
286 }
282 } 287 }
283 } 288 }
284 } 289 }
@@ -307,7 +312,7 @@ struct RepoSettingsView: View {
307 } header: { 312 } header: {
308 Text("Access") 313 Text("Access")
309 } footer: { 314 } footer: {
310 Text("Direct grants only. Ownership and org membership carry their own access.") 315 Text("Everyone who can reach this repository and how. Only a direct grant can be revoked here; a role change grants directly.")
311 } 316 }
312 } 317 }
313 318
gitbayTests/RepoManagementTests.swift +11 −3
@@ -114,14 +114,22 @@ struct RepoSettingsViewModelTests {
114 @Test func accessGrantsLoadSeparately() async throws { 114 @Test func accessGrantsLoadSeparately() async throws {
115 let (model, stub) = try await loadedModel() 115 let (model, stub) = try await loadedModel()
116 stub.enqueue(.init(status: 200, json: """ 116 stub.enqueue(.init(status: 200, json: """
117 {"protocol_version":1,"data":[{"user":"alice","role":"write"},\ 117 {"protocol_version":1,"data":[{"user":"alice","role":"write","source":"direct"},\
118 {"user":"bob","role":"read"}],"exit_code":0} 118 {"user":"bob","role":"read","source":"team"},{"user":"cmc","role":"admin"}],"exit_code":0}
119 """, match: "argv=access")) 119 """, match: "argv=access"))
120 120
121 await model.loadGrants() 121 await model.loadGrants()
122 122
123 #expect(model.grants?.map(\.user) == ["alice", "bob"]) 123 #expect(model.grants?.map(\.user) == ["alice", "bob", "cmc"])
124 #expect(model.grants?.first?.role == "write") 124 #expect(model.grants?.first?.role == "write")
125 // v1.16.0 says where the access comes from; only a direct grant
126 // can be revoked here. An older server says nothing.
127 #expect(model.grants?[0].source == "direct")
128 #expect(model.grants?[0].isDirect == true)
129 #expect(model.grants?[1].source == "team")
130 #expect(model.grants?[1].isDirect == false)
131 #expect(model.grants?[2].source == nil)
132 #expect(model.grants?[2].isDirect == true)
125 #expect(model.grantsError == nil) 133 #expect(model.grantsError == nil)
126 let read = try #require(stub.seen.last) 134 let read = try #require(stub.seen.last)
127 #expect(read.url.query() == "argv=repo&argv=access&argv=list&argv=krz/gitbay") 135 #expect(read.url.query() == "argv=repo&argv=access&argv=list&argv=krz/gitbay")