Commit 3b74c3360e

3b74c3360e1fecd830f9d21e7725bd8603dc60cd

parent: 0c4b027468

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 03:16 UTC

feat: add repository acl management

Implements: https://todo.sr.ht/~ccleberg/hutch/39
Implements: https://todo.sr.ht/~ccleberg/hutch/40
Implements: https://todo.sr.ht/~ccleberg/hutch/41

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 43;
518 CURRENT_PROJECT_VERSION = 44;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.19.0;
535 MARKETING_VERSION = 2.19.1;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 43;
555 CURRENT_PROJECT_VERSION = 44;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.19.0;
572 MARKETING_VERSION = 2.19.1;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 43;
635 CURRENT_PROJECT_VERSION = 44;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.19.0;
645 MARKETING_VERSION = 2.19.1;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 43;
664 CURRENT_PROJECT_VERSION = 44;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.19.0;
674 MARKETING_VERSION = 2.19.1;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Models/Git.swift +1 −1
@@ -10,7 +10,7 @@ enum Visibility: String, Codable, Sendable {
1010}
1111
1212/// Repository access mode.
13enum AccessMode: String, Codable, Sendable {
13enum AccessMode: String, Codable, Sendable, CaseIterable {
1414 case ro = "RO"
1515 case rw = "RW"
1616}
Hutch/Models/RepositoryACL.swift added +20
@@ -0,0 +1,20 @@
1import Foundation
2
3struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable {
4 let id: Int
5 let mode: AccessMode
6 let entity: Entity
7}
8
9extension AccessMode {
10 var shortLabel: String { rawValue }
11
12 var displayName: String {
13 switch self {
14 case .ro:
15 "Read Only"
16 case .rw:
17 "Read/Write"
18 }
19 }
20}
Hutch/Networking/RepositoryACLService.swift added +106
@@ -0,0 +1,106 @@
1import Foundation
2
3protocol RepositoryACLServicing {
4 func fetchACLs(repositoryRid: String) async throws -> [RepositoryACLEntry]
5 func upsertACL(repositoryId: Int, entity: String, mode: AccessMode) async throws -> RepositoryACLEntry
6 func deleteACL(entryId: Int) async throws
7}
8
9private struct RepositoryACLQueryResponse: Decodable, Sendable {
10 let repository: RepositoryACLQueryRepository?
11}
12
13private struct RepositoryACLQueryRepository: Decodable, Sendable {
14 let acls: RepositoryACLPage
15}
16
17private struct RepositoryACLPage: Decodable, Sendable {
18 let results: [RepositoryACLEntry]
19}
20
21private struct RepositoryACLMutationResponse: Decodable, Sendable {
22 let updateACL: RepositoryACLEntry
23}
24
25private struct RepositoryACLDeleteResponse: Decodable, Sendable {
26 let deleteACL: RepositoryACLDeletedEntry
27}
28
29private struct RepositoryACLDeletedEntry: Decodable, Sendable {
30 let id: Int
31}
32
33struct RepositoryACLService: RepositoryACLServicing {
34 private let client: SRHTClient
35 private let service: SRHTService
36
37 init(client: SRHTClient, service: SRHTService) {
38 self.client = client
39 self.service = service
40 }
41
42 func fetchACLs(repositoryRid: String) async throws -> [RepositoryACLEntry] {
43 let response = try await client.execute(
44 service: service,
45 query: Self.aclsQuery,
46 variables: ["rid": repositoryRid],
47 responseType: RepositoryACLQueryResponse.self
48 )
49 return response.repository?.acls.results ?? []
50 }
51
52 func upsertACL(repositoryId: Int, entity: String, mode: AccessMode) async throws -> RepositoryACLEntry {
53 let response = try await client.execute(
54 service: service,
55 query: Self.upsertACLMutation,
56 variables: [
57 "repoId": repositoryId,
58 "entity": entity,
59 "mode": mode.rawValue
60 ],
61 responseType: RepositoryACLMutationResponse.self
62 )
63 return response.updateACL
64 }
65
66 func deleteACL(entryId: Int) async throws {
67 _ = try await client.execute(
68 service: service,
69 query: Self.deleteACLMutation,
70 variables: ["id": entryId],
71 responseType: RepositoryACLDeleteResponse.self
72 )
73 }
74}
75
76private extension RepositoryACLService {
77 static let aclsQuery = """
78 query repositoryACLs($rid: ID!) {
79 repository(rid: $rid) {
80 acls {
81 results {
82 id
83 mode
84 entity { canonicalName }
85 }
86 }
87 }
88 }
89 """
90
91 static let upsertACLMutation = """
92 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
93 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
94 id
95 mode
96 entity { canonicalName }
97 }
98 }
99 """
100
101 static let deleteACLMutation = """
102 mutation deleteACL($id: Int!) {
103 deleteACL(id: $id) { id }
104 }
105 """
106}
Hutch/Views/Repositories/RepositoryACLView.swift added +239
@@ -0,0 +1,239 @@
1import SwiftUI
2
3struct RepositoryACLView: View {
4 let repository: RepositorySummary
5 let client: SRHTClient
6 let showsDoneButton: Bool
7
8 @Environment(\.dismiss) private var dismiss
9 @State private var viewModel: RepositoryACLViewModel?
10 @State private var pendingDeletion: RepositoryACLEntry?
11 @State private var showAddSheet = false
12
13 var body: some View {
14 Group {
15 if let viewModel {
16 content(viewModel)
17 } else {
18 SRHTLoadingStateView(message: "Loading access…")
19 }
20 }
21 .navigationTitle("Access")
22 .navigationBarTitleDisplayMode(.inline)
23 .toolbar {
24 if showsDoneButton {
25 ToolbarItem(placement: .cancellationAction) {
26 Button("Done") { dismiss() }
27 }
28 }
29
30 if viewModel != nil {
31 ToolbarItem(placement: .primaryAction) {
32 Button {
33 showAddSheet = true
34 } label: {
35 Image(systemName: "plus")
36 }
37 .accessibilityLabel("Add User")
38 }
39 }
40 }
41 .task {
42 if viewModel == nil {
43 let service = RepositoryACLService(client: client, service: repository.service)
44 let vm = RepositoryACLViewModel(repository: repository, service: service)
45 viewModel = vm
46 await vm.load()
47 }
48 }
49 }
50
51 @ViewBuilder
52 private func content(_ viewModel: RepositoryACLViewModel) -> some View {
53 @Bindable var vm = viewModel
54
55 Group {
56 if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
57 SRHTLoadingStateView(message: "Loading access…")
58 } else if let loadError = viewModel.loadError, !viewModel.hasEntries {
59 SRHTErrorStateView(
60 title: "Couldn't Load Access",
61 message: loadError,
62 retryAction: { await viewModel.load() }
63 )
64 } else {
65 List {
66 if viewModel.visibleEntries.isEmpty {
67 ContentUnavailableView {
68 Label("No Additional Access", systemImage: "person.2.slash")
69 } description: {
70 Text("Only the repository owner currently has access.")
71 }
72 .frame(maxWidth: .infinity)
73 .listRowBackground(Color.clear)
74 } else {
75 Section {
76 ForEach(viewModel.visibleEntries) { entry in
77 RepositoryACLEntryRow(
78 entry: entry,
79 isUpdating: viewModel.isUpdating(entry),
80 isDeleting: viewModel.isDeleting(entry),
81 onSelectMode: { mode in
82 Task { await viewModel.updatePermission(for: entry, to: mode) }
83 },
84 onDelete: {
85 pendingDeletion = entry
86 }
87 )
88 }
89 }
90 }
91 }
92 .listStyle(.insetGrouped)
93 .refreshable {
94 await viewModel.load()
95 }
96 }
97 }
98 .srhtErrorBanner(error: $vm.error)
99 .alert("Remove Access?", isPresented: Binding(
100 get: { pendingDeletion != nil },
101 set: { isPresented in
102 if !isPresented {
103 pendingDeletion = nil
104 }
105 }
106 )) {
107 Button("Cancel", role: .cancel) {}
108 Button("Remove Access", role: .destructive) {
109 guard let entry = pendingDeletion else { return }
110 Task {
111 await viewModel.removeEntry(entry)
112 pendingDeletion = nil
113 }
114 }
115 } message: {
116 if let entry = pendingDeletion {
117 Text("\(entry.entity.canonicalName) will lose \(entry.mode.displayName.lowercased()) access to this repository.")
118 }
119 }
120 .sheet(isPresented: $showAddSheet) {
121 NavigationStack {
122 RepositoryACLAddUserView(viewModel: viewModel) {
123 showAddSheet = false
124 }
125 }
126 }
127 }
128}
129
130private struct RepositoryACLEntryRow: View {
131 let entry: RepositoryACLEntry
132 let isUpdating: Bool
133 let isDeleting: Bool
134 let onSelectMode: (AccessMode) -> Void
135 let onDelete: () -> Void
136
137 var body: some View {
138 HStack(alignment: .center, spacing: 12) {
139 Text(entry.entity.canonicalName)
140 .font(.body.monospaced())
141 .lineLimit(2)
142 .truncationMode(.middle)
143 .frame(maxWidth: .infinity, alignment: .leading)
144
145 if isUpdating || isDeleting {
146 ProgressView()
147 .controlSize(.small)
148 }
149
150 Menu {
151 ForEach(AccessMode.allCases, id: \.self) { mode in
152 Button {
153 onSelectMode(mode)
154 } label: {
155 if mode == entry.mode {
156 Label(mode.displayName, systemImage: "checkmark")
157 } else {
158 Text(mode.displayName)
159 }
160 }
161 }
162 } label: {
163 Text(entry.mode.shortLabel)
164 .font(.caption.monospaced())
165 .foregroundStyle(.secondary)
166 .padding(.horizontal, 10)
167 .padding(.vertical, 6)
168 .background(.quaternary, in: Capsule())
169 }
170 .disabled(isUpdating || isDeleting)
171 }
172 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
173 Button(role: .destructive) {
174 onDelete()
175 } label: {
176 Label("Remove", systemImage: "trash")
177 }
178 .disabled(isUpdating || isDeleting)
179 }
180 }
181}
182
183private struct RepositoryACLAddUserView: View {
184 @Environment(\.dismiss) private var dismiss
185
186 @Bindable var viewModel: RepositoryACLViewModel
187 let onAdded: () -> Void
188
189 var body: some View {
190 Form {
191 Section("User") {
192 TextField("Username or ~username", text: $viewModel.addUsername)
193 .autocorrectionDisabled()
194 .textInputAutocapitalization(.never)
195
196 if let validation = inlineValidationMessage {
197 Text(validation)
198 .font(.caption)
199 .foregroundStyle(.secondary)
200 }
201 }
202
203 Section("Permission") {
204 Picker("Permission", selection: $viewModel.addMode) {
205 ForEach(AccessMode.allCases, id: \.self) { mode in
206 Text(mode.shortLabel).tag(mode)
207 }
208 }
209 .pickerStyle(.segmented)
210 }
211 }
212 .navigationTitle("Add User")
213 .navigationBarTitleDisplayMode(.inline)
214 .toolbar {
215 ToolbarItem(placement: .cancellationAction) {
216 Button("Cancel") {
217 dismiss()
218 }
219 }
220
221 ToolbarItem(placement: .confirmationAction) {
222 Button("Add") {
223 Task {
224 if await viewModel.addEntry() {
225 onAdded()
226 }
227 }
228 }
229 .disabled(!viewModel.canSubmitNewEntry)
230 }
231 }
232 }
233
234 private var inlineValidationMessage: String? {
235 let trimmed = viewModel.addUsername.trimmingCharacters(in: .whitespacesAndNewlines)
236 guard !trimmed.isEmpty else { return nil }
237 return viewModel.addValidationMessage
238 }
239}
Hutch/Views/Repositories/RepositoryACLViewModel.swift added +213
@@ -0,0 +1,213 @@
1import Foundation
2
3@Observable
4@MainActor
5final class RepositoryACLViewModel {
6 let repository: RepositorySummary
7
8 private let service: any RepositoryACLServicing
9
10 private(set) var entries: [RepositoryACLEntry] = []
11 private(set) var isLoading = false
12 private(set) var loadError: String?
13 private(set) var updatingEntryIDs: Set<Int> = []
14 private(set) var deletingEntryIDs: Set<Int> = []
15 private(set) var isCreatingEntry = false
16
17 var addUsername = ""
18 var addMode: AccessMode = .ro
19 var error: String?
20
21 init(
22 repository: RepositorySummary,
23 service: any RepositoryACLServicing
24 ) {
25 self.repository = repository
26 self.service = service
27 }
28
29 var visibleEntries: [RepositoryACLEntry] {
30 sort(entries.filter { normalizedIdentity($0.entity.canonicalName) != normalizedIdentity(repository.owner.canonicalName) })
31 }
32
33 var hasEntries: Bool {
34 !visibleEntries.isEmpty
35 }
36
37 var addValidationMessage: String? {
38 Self.validateEntityInput(
39 addUsername,
40 ownerCanonicalName: repository.owner.canonicalName,
41 existingEntities: visibleEntries.map(\.entity.canonicalName)
42 )
43 }
44
45 var canSubmitNewEntry: Bool {
46 addValidationMessage == nil && !isCreatingEntry
47 }
48
49 func load() async {
50 guard !isLoading else { return }
51
52 isLoading = true
53 defer { isLoading = false }
54
55 do {
56 entries = try await service.fetchACLs(repositoryRid: repository.rid)
57 loadError = nil
58 } catch {
59 let message = error.userFacingMessage
60 if entries.isEmpty {
61 loadError = message
62 } else {
63 self.error = message
64 }
65 }
66 }
67
68 func addEntry() async -> Bool {
69 guard let entity = validatedNewEntity() else {
70 error = addValidationMessage ?? "Enter a valid username."
71 return false
72 }
73
74 isCreatingEntry = true
75 defer { isCreatingEntry = false }
76 error = nil
77
78 do {
79 let entry = try await service.upsertACL(
80 repositoryId: repository.id,
81 entity: entity,
82 mode: addMode
83 )
84 merge(entry)
85 addUsername = ""
86 addMode = .ro
87 await refreshAfterMutation()
88 return true
89 } catch {
90 self.error = error.userFacingMessage
91 return false
92 }
93 }
94
95 func updatePermission(for entry: RepositoryACLEntry, to mode: AccessMode) async {
96 guard entry.mode != mode else { return }
97
98 updatingEntryIDs.insert(entry.id)
99 defer { updatingEntryIDs.remove(entry.id) }
100 error = nil
101
102 do {
103 let updatedEntry = try await service.upsertACL(
104 repositoryId: repository.id,
105 entity: entry.entity.canonicalName,
106 mode: mode
107 )
108 merge(updatedEntry)
109 await refreshAfterMutation()
110 } catch {
111 self.error = error.userFacingMessage
112 }
113 }
114
115 func removeEntry(_ entry: RepositoryACLEntry) async {
116 deletingEntryIDs.insert(entry.id)
117 defer { deletingEntryIDs.remove(entry.id) }
118 error = nil
119
120 do {
121 try await service.deleteACL(entryId: entry.id)
122 entries.removeAll { $0.id == entry.id }
123 await refreshAfterMutation()
124 } catch {
125 self.error = error.userFacingMessage
126 }
127 }
128
129 func isUpdating(_ entry: RepositoryACLEntry) -> Bool {
130 updatingEntryIDs.contains(entry.id)
131 }
132
133 func isDeleting(_ entry: RepositoryACLEntry) -> Bool {
134 deletingEntryIDs.contains(entry.id)
135 }
136
137 static func canonicalEntity(from input: String) -> String? {
138 guard validateEntityInput(input, ownerCanonicalName: nil, existingEntities: []) == nil else {
139 return nil
140 }
141
142 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
143 let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
144 return "~\(username)"
145 }
146
147 static func validateEntityInput(
148 _ input: String,
149 ownerCanonicalName: String?,
150 existingEntities: [String]
151 ) -> String? {
152 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
153 guard !trimmed.isEmpty else { return "Enter a username." }
154 guard !trimmed.contains(where: \.isWhitespace) else { return "Usernames cannot contain spaces." }
155
156 let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
157 guard !username.isEmpty else { return "Enter a username." }
158 guard username.first != "~", !username.contains("~") else { return "Enter a valid username." }
159 guard username.range(of: #"^[A-Za-z0-9][A-Za-z0-9._-]*$"#, options: .regularExpression) != nil else {
160 return "Enter a valid username."
161 }
162
163 if let ownerCanonicalName, normalizedIdentity(ownerCanonicalName) == normalizedIdentity(username) {
164 return "The repository owner already has access."
165 }
166
167 if existingEntities.contains(where: { normalizedIdentity($0) == normalizedIdentity(username) }) {
168 return "That user already has access."
169 }
170
171 return nil
172 }
173}
174
175private extension RepositoryACLViewModel {
176 func validatedNewEntity() -> String? {
177 guard addValidationMessage == nil else { return nil }
178 return Self.canonicalEntity(from: addUsername)
179 }
180
181 func refreshAfterMutation() async {
182 do {
183 entries = try await service.fetchACLs(repositoryRid: repository.rid)
184 loadError = nil
185 } catch {
186 self.error = "Saved, but couldn't refresh access list: \(error.userFacingMessage)"
187 }
188 }
189
190 func merge(_ entry: RepositoryACLEntry) {
191 if let index = entries.firstIndex(where: { $0.id == entry.id }) {
192 entries[index] = entry
193 } else {
194 entries.append(entry)
195 }
196 entries = sort(entries)
197 }
198
199 func sort(_ entries: [RepositoryACLEntry]) -> [RepositoryACLEntry] {
200 entries.sorted {
201 $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending
202 }
203 }
204
205 static func normalizedIdentity(_ value: String) -> String {
206 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
207 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()).lowercased() : trimmed.lowercased()
208 }
209
210 func normalizedIdentity(_ value: String) -> String {
211 Self.normalizedIdentity(value)
212 }
213}
Hutch/Views/Repositories/RepositoryDetailView.swift +16
@@ -9,6 +9,7 @@ struct RepositoryDetailView: View {
99 @State private var viewModel: RepositoryDetailViewModel?
1010 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
1111 @State private var showSettings = false
12 @State private var showACLs = false
1213 @State private var displayName: String
1314
1415 private var canManageRepository: Bool {
@@ -42,6 +43,12 @@ struct RepositoryDetailView: View {
4243 }
4344
4445 if canManageRepository {
46 Button {
47 showACLs = true
48 } label: {
49 Image(systemName: "person.2")
50 }
51
4552 Button {
4653 showSettings = true
4754 } label: {
@@ -64,6 +71,15 @@ struct RepositoryDetailView: View {
6471 }
6572 )
6673 }
74 .sheet(isPresented: $showACLs) {
75 NavigationStack {
76 RepositoryACLView(
77 repository: repository,
78 client: appState.client,
79 showsDoneButton: true
80 )
81 }
82 }
6783 .task {
6884 if viewModel == nil {
6985 viewModel = RepositoryDetailViewModel(
Hutch/Views/Repositories/RepositorySettingsView.swift +7 −78
@@ -11,7 +11,6 @@ struct RepositorySettingsView: View {
1111 @State private var viewModel: RepositorySettingsViewModel?
1212 @State private var showDeleteConfirmation = false
1313 @State private var showRenameConfirmation = false
14 @State private var pendingACLDeletion: ACLEntry?
1514 @State private var saveResultAlert: SaveResultAlert?
1615
1716 var body: some View {
@@ -39,7 +38,6 @@ struct RepositorySettingsView: View {
3938 client: client
4039 )
4140 viewModel = vm
42 await vm.loadACLs()
4341 }
4442 }
4543 }
@@ -51,7 +49,7 @@ struct RepositorySettingsView: View {
5149 Form {
5250 infoSection(viewModel)
5351 renameSection(viewModel)
54 accessSection(viewModel)
52 accessSection()
5553 deleteSection(viewModel)
5654 }
5755 .srhtErrorBanner(error: $vm.error)
@@ -93,29 +91,6 @@ struct RepositorySettingsView: View {
9391 } message: {
9492 Text("This cannot be undone.")
9593 }
96 .alert("Remove Access?", isPresented: Binding(
97 get: { pendingACLDeletion != nil },
98 set: { isPresented in
99 if !isPresented {
100 pendingACLDeletion = nil
101 }
102 }
103 )) {
104 Button("Cancel", role: .cancel) {
105 // Alert dismissal is implicit; no additional action required.
106 }
107 Button("Remove Access", role: .destructive) {
108 guard let entry = pendingACLDeletion else { return }
109 Task {
110 await viewModel.deleteACL(entry)
111 pendingACLDeletion = nil
112 }
113 }
114 } message: {
115 if let entry = pendingACLDeletion {
116 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
117 }
118 }
11994 .alert(item: $saveResultAlert) { alert in
12095 Alert(
12196 title: Text(alert.title),
@@ -207,61 +182,15 @@ struct RepositorySettingsView: View {
207182 // MARK: - Access Section
208183
209184 @ViewBuilder
210 private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
185 private func accessSection() -> some View {
211186 Section {
212 if viewModel.isLoadingACLs {
213 HStack {
214 Spacer()
215 ProgressView()
216 Spacer()
217 }
218 } else if viewModel.acls.isEmpty {
219 Text("No access entries yet.")
220 .foregroundStyle(.secondary)
221 } else {
222 ForEach(viewModel.acls) { entry in
223 HStack {
224 Text(entry.entity.canonicalName)
225 Spacer()
226 Text(entry.mode)
227 .font(.caption.monospaced())
228 .foregroundStyle(.secondary)
229 }
230 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
231 Button(role: .destructive) {
232 pendingACLDeletion = entry
233 } label: {
234 Label("Remove Access", systemImage: "trash")
235 }
236 }
237 }
187 NavigationLink {
188 RepositoryACLView(repository: repository, client: client, showsDoneButton: false)
189 } label: {
190 Label("Manage Access", systemImage: "person.2")
238191 }
239192
240 // Add ACL form
241 HStack {
242 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
243 .autocorrectionDisabled()
244 .textInputAutocapitalization(.never)
245
246 Picker("", selection: Bindable(viewModel).newACLMode) {
247 Text("RO").tag("RO")
248 Text("RW").tag("RW")
249 }
250 .pickerStyle(.segmented)
251 .frame(width: 100)
252
253 Button {
254 Task { await viewModel.addACL() }
255 } label: {
256 if viewModel.isAddingACL {
257 ProgressView()
258 } else {
259 Text("Add")
260 }
261 }
262 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
263 }
264 Text("Add a SourceHut user and choose read-only or read/write access.")
193 Text("Review and update repository access without leaving settings.")
265194 .font(.caption)
266195 .foregroundStyle(.secondary)
267196 } header: {
Hutch/Views/Repositories/RepositorySettingsViewModel.swift −157
@@ -22,31 +22,6 @@ private struct UpdatedRepoInfo: Decodable, Sendable {
2222 let id: Int
2323}
2424
25private struct ACLResponse: Decodable, Sendable {
26 let repository: ACLRepository?
27}
28
29private struct ACLRepository: Decodable, Sendable {
30 let acls: ACLPage
31}
32
33private struct ACLPage: Decodable, Sendable {
34 let results: [ACLEntry]
35 let cursor: String?
36}
37
38private struct UpdateACLResponse: Decodable, Sendable {
39 let updateACL: ACLEntry
40}
41
42private struct DeleteACLResponse: Decodable, Sendable {
43 let deleteACL: DeletedACL
44}
45
46private struct DeletedACL: Decodable, Sendable {
47 let id: Int
48}
49
5025private struct DeleteRepoResponse: Decodable, Sendable {
5126 let deleteRepository: DeletedRepo
5227}
@@ -55,14 +30,6 @@ private struct DeletedRepo: Decodable, Sendable {
5530 let id: Int
5631}
5732
58// MARK: - ACL Model
59
60struct ACLEntry: Decodable, Sendable, Identifiable {
61 let id: Int
62 let mode: String
63 let entity: Entity
64}
65
6633// MARK: - View Model
6734
6835@Observable
@@ -87,15 +54,6 @@ final class RepositorySettingsViewModel {
8754 var editedName: String
8855 var isRenaming = false
8956
90 // MARK: - ACL state
91
92 private(set) var acls: [ACLEntry] = []
93 private(set) var isLoadingACLs = false
94 var newACLEntity = ""
95 var newACLMode = "RO"
96 var isAddingACL = false
97 var isDeletingACL = false
98
9957 // MARK: - Delete state
10058
10159 var isDeleting = false
@@ -202,103 +160,6 @@ final class RepositorySettingsViewModel {
202160 }
203161 }
204162
205 // MARK: - ACLs
206
207 private static let aclsQuery = """
208 query acls($rid: ID!) {
209 repository(rid: $rid) {
210 acls {
211 results {
212 id
213 mode
214 entity { canonicalName }
215 }
216 cursor
217 }
218 }
219 }
220 """
221
222 private static let updateACLMutation = """
223 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
224 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
225 id mode entity { canonicalName }
226 }
227 }
228 """
229
230 private static let deleteACLMutation = """
231 mutation deleteACL($id: Int!) {
232 deleteACL(id: $id) { id }
233 }
234 """
235
236 private static let userLookupQuery = """
237 query userLookup($username: String!) {
238 user(username: $username) {
239 id
240 username
241 canonicalName
242 }
243 }
244 """
245
246 func loadACLs() async {
247 guard !isLoadingACLs else { return }
248 isLoadingACLs = true
249 defer { isLoadingACLs = false }
250
251 do {
252 let result = try await client.execute(
253 service: service,
254 query: Self.aclsQuery,
255 variables: ["rid": repositoryRid],
256 responseType: ACLResponse.self
257 )
258 acls = result.repository?.acls.results ?? []
259 } catch {
260 self.error = error.userFacingMessage
261 }
262 }
263
264 func addACL() async {
265 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
266 guard !rawEntity.isEmpty else { return }
267 let entity = Self.gitCanonicalEntity(from: rawEntity)
268 isAddingACL = true
269 defer { isAddingACL = false }
270 error = nil
271
272 do {
273 let result = try await client.execute(
274 service: service,
275 query: Self.updateACLMutation,
276 variables: [
277 "repoId": repositoryId,
278 "mode": newACLMode,
279 "entity": entity
280 ],
281 responseType: UpdateACLResponse.self
282 )
283 // Replace existing entry or append
284 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
285 acls[index] = result.updateACL
286 } else {
287 acls.append(result.updateACL)
288 }
289 newACLEntity = ""
290 } catch {
291 self.error = error.userFacingMessage
292 }
293 }
294
295 static func gitCanonicalEntity(from input: String) -> String {
296 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
297 guard !trimmed.isEmpty else { return trimmed }
298 let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
299 return "~\(username)"
300 }
301
302163 static func gitHeadReference(from input: String) -> String {
303164 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
304165 guard !trimmed.isEmpty else { return trimmed }
@@ -319,24 +180,6 @@ final class RepositorySettingsViewModel {
319180 }?.name
320181 }
321182
322 func deleteACL(_ entry: ACLEntry) async {
323 isDeletingACL = true
324 defer { isDeletingACL = false }
325 error = nil
326
327 do {
328 _ = try await client.execute(
329 service: service,
330 query: Self.deleteACLMutation,
331 variables: ["id": entry.id],
332 responseType: DeleteACLResponse.self
333 )
334 acls.removeAll { $0.id == entry.id }
335 } catch {
336 self.error = error.userFacingMessage
337 }
338 }
339
340183 // MARK: - Delete Repository
341184
342185 private static let deleteRepoMutation = """
HutchTests/RepositoryACLViewModelTests.swift added +173
@@ -0,0 +1,173 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct RepositoryACLViewModelTests {
6
7 @Test
8 @MainActor
9 func addValidationRejectsOwnerAndDuplicates() async {
10 let service = MockRepositoryACLService()
11 service.fetchResponses = [[
12 RepositoryACLEntry(
13 id: 2,
14 mode: .ro,
15 entity: Entity(canonicalName: "~alice")
16 )
17 ]]
18 let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service)
19 viewModel.addUsername = "~owner"
20 #expect(viewModel.addValidationMessage == "The repository owner already has access.")
21
22 await viewModel.load()
23 viewModel.addUsername = "alice"
24 #expect(viewModel.addValidationMessage == "That user already has access.")
25 }
26
27 @Test
28 @MainActor
29 func addEntryRefreshesListAfterSuccess() async {
30 let service = MockRepositoryACLService()
31 service.fetchResponses = [[
32 RepositoryACLEntry(
33 id: 2,
34 mode: .rw,
35 entity: Entity(canonicalName: "~alice")
36 )
37 ]]
38
39 let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service)
40 viewModel.addUsername = "alice"
41 viewModel.addMode = .rw
42
43 let didAdd = await viewModel.addEntry()
44
45 #expect(didAdd)
46 #expect(service.upsertRequests == [MockRepositoryACLService.UpsertRequest(repositoryId: 1, entity: "~alice", mode: .rw)])
47 #expect(service.fetchRequestRids == ["rid-1"])
48 #expect(viewModel.visibleEntries.map(\.entity.canonicalName) == ["~alice"])
49 #expect(viewModel.addUsername.isEmpty)
50 }
51
52 @Test
53 @MainActor
54 func permissionUpdateFailureLeavesExistingEntryUntouched() async {
55 let service = MockRepositoryACLService()
56 service.upsertError = SRHTError.httpError(500)
57
58 let entry = RepositoryACLEntry(
59 id: 2,
60 mode: .ro,
61 entity: Entity(canonicalName: "~alice")
62 )
63 let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service)
64 service.fetchResponses = [[entry]]
65 await viewModel.load()
66
67 await viewModel.updatePermission(for: entry, to: .rw)
68
69 #expect(viewModel.visibleEntries.first?.mode == .ro)
70 #expect(viewModel.updatingEntryIDs.isEmpty)
71 #expect(viewModel.error != nil)
72 }
73
74 @Test
75 @MainActor
76 func removeFailureKeepsEntryVisible() async {
77 let service = MockRepositoryACLService()
78 service.deleteError = SRHTError.httpError(500)
79
80 let entry = RepositoryACLEntry(
81 id: 2,
82 mode: .ro,
83 entity: Entity(canonicalName: "~alice")
84 )
85 let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service)
86 service.fetchResponses = [[entry]]
87 await viewModel.load()
88
89 await viewModel.removeEntry(entry)
90
91 #expect(viewModel.visibleEntries.map(\.id) == [2])
92 #expect(viewModel.deletingEntryIDs.isEmpty)
93 #expect(viewModel.error != nil)
94 }
95
96 @Test
97 @MainActor
98 func initialLoadFailureSetsBlockingErrorState() async {
99 let service = MockRepositoryACLService()
100 service.fetchError = SRHTError.httpError(500)
101
102 let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service)
103
104 await viewModel.load()
105
106 #expect(viewModel.loadError != nil)
107 #expect(viewModel.visibleEntries.isEmpty)
108 }
109
110 @MainActor
111 private func makeRepository() -> RepositorySummary {
112 RepositorySummary(
113 id: 1,
114 rid: "rid-1",
115 service: .git,
116 name: "repo",
117 description: nil,
118 visibility: .public,
119 updated: .now,
120 owner: Entity(canonicalName: "~owner"),
121 head: nil
122 )
123 }
124}
125
126@MainActor
127private final class MockRepositoryACLService: RepositoryACLServicing {
128 struct UpsertRequest: Equatable {
129 let repositoryId: Int
130 let entity: String
131 let mode: AccessMode
132 }
133
134 var fetchResponses: [[RepositoryACLEntry]] = []
135 var fetchError: Error?
136 var upsertResponse = RepositoryACLEntry(
137 id: 2,
138 mode: .ro,
139 entity: Entity(canonicalName: "~alice")
140 )
141 var upsertError: Error?
142 var deleteError: Error?
143
144 private(set) var fetchRequestRids: [String] = []
145 private(set) var upsertRequests: [UpsertRequest] = []
146 private(set) var deleteRequestIDs: [Int] = []
147
148 func fetchACLs(repositoryRid: String) async throws -> [RepositoryACLEntry] {
149 fetchRequestRids.append(repositoryRid)
150 if let fetchError {
151 throw fetchError
152 }
153 if !fetchResponses.isEmpty {
154 return fetchResponses.removeFirst()
155 }
156 return []
157 }
158
159 func upsertACL(repositoryId: Int, entity: String, mode: AccessMode) async throws -> RepositoryACLEntry {
160 upsertRequests.append(UpsertRequest(repositoryId: repositoryId, entity: entity, mode: mode))
161 if let upsertError {
162 throw upsertError
163 }
164 return RepositoryACLEntry(id: upsertResponse.id, mode: mode, entity: Entity(canonicalName: entity))
165 }
166
167 func deleteACL(entryId: Int) async throws {
168 deleteRequestIDs.append(entryId)
169 if let deleteError {
170 throw deleteError
171 }
172 }
173}
HutchTests/RepositorySettingsViewModelTests.swift +3 −3
@@ -35,9 +35,9 @@ struct RepositorySettingsViewModelTests {
3535 @Test
3636 @MainActor
3737 func gitCanonicalEntityAddsMissingTilde() {
38 #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice")
39 #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice")
40 #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice")
38 #expect(RepositoryACLViewModel.canonicalEntity(from: "alice") == "~alice")
39 #expect(RepositoryACLViewModel.canonicalEntity(from: "~alice") == "~alice")
40 #expect(RepositoryACLViewModel.canonicalEntity(from: " alice ") == "~alice")
4141 }
4242
4343 @Test