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. |
| 31 | nonisolated struct AccessGrant: Decodable, Sendable, Hashable, Identifiable { |
34 | nonisolated 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") |