Commit 3b74c3360e
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | |||
| 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | CODE_SIGN_STYLE = Automatic; | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 43; | 518 | CURRENT_PROJECT_VERSION = 44; |
| 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | ENABLE_PREVIEWS = YES; | 520 | ENABLE_PREVIEWS = YES; |
| 521 | GENERATE_INFOPLIST_FILE = YES; | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | |||
| 532 | "$(inherited)", | 532 | "$(inherited)", |
| 533 | "@executable_path/Frameworks", | 533 | "@executable_path/Frameworks", |
| 534 | ); | 534 | ); |
| 535 | MARKETING_VERSION = 2.19.0; | 535 | MARKETING_VERSION = 2.19.1; |
| 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | PRODUCT_NAME = "$(TARGET_NAME)"; | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | |||
| 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | CODE_SIGN_STYLE = Automatic; | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 43; | 555 | CURRENT_PROJECT_VERSION = 44; |
| 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | ENABLE_PREVIEWS = YES; | 557 | ENABLE_PREVIEWS = YES; |
| 558 | GENERATE_INFOPLIST_FILE = YES; | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | |||
| 569 | "$(inherited)", | 569 | "$(inherited)", |
| 570 | "@executable_path/Frameworks", | 570 | "@executable_path/Frameworks", |
| 571 | ); | 571 | ); |
| 572 | MARKETING_VERSION = 2.19.0; | 572 | MARKETING_VERSION = 2.19.1; |
| 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | PRODUCT_NAME = "$(TARGET_NAME)"; | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | |||
| 632 | APPLICATION_EXTENSION_API_ONLY = YES; | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | CODE_SIGN_STYLE = Automatic; | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 43; | 635 | CURRENT_PROJECT_VERSION = 44; |
| 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | GENERATE_INFOPLIST_FILE = NO; | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | |||
| 642 | "@executable_path/Frameworks", | 642 | "@executable_path/Frameworks", |
| 643 | "@executable_path/../../Frameworks", | 643 | "@executable_path/../../Frameworks", |
| 644 | ); | 644 | ); |
| 645 | MARKETING_VERSION = 2.19.0; | 645 | MARKETING_VERSION = 2.19.1; |
| 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | PRODUCT_NAME = "$(TARGET_NAME)"; | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | SKIP_INSTALL = YES; | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | |||
| 661 | APPLICATION_EXTENSION_API_ONLY = YES; | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | CODE_SIGN_STYLE = Automatic; | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 43; | 664 | CURRENT_PROJECT_VERSION = 44; |
| 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | GENERATE_INFOPLIST_FILE = NO; | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | |||
| 671 | "@executable_path/Frameworks", | 671 | "@executable_path/Frameworks", |
| 672 | "@executable_path/../../Frameworks", | 672 | "@executable_path/../../Frameworks", |
| 673 | ); | 673 | ); |
| 674 | MARKETING_VERSION = 2.19.0; | 674 | MARKETING_VERSION = 2.19.1; |
| 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | PRODUCT_NAME = "$(TARGET_NAME)"; | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | SKIP_INSTALL = YES; | 677 | SKIP_INSTALL = YES; |
Hutch/Models/Git.swift +1 −1
| @@ -10,7 +10,7 @@ enum Visibility: String, Codable, Sendable { | |||
| 10 | } | 10 | } |
| 11 | 11 | ||
| 12 | /// Repository access mode. | 12 | /// Repository access mode. |
| 13 | enum AccessMode: String, Codable, Sendable { | 13 | enum AccessMode: String, Codable, Sendable, CaseIterable { |
| 14 | case ro = "RO" | 14 | case ro = "RO" |
| 15 | case rw = "RW" | 15 | case rw = "RW" |
| 16 | } | 16 | } |
Hutch/Models/RepositoryACL.swift added +20
| @@ -0,0 +1,20 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable { | ||
| 4 | let id: Int | ||
| 5 | let mode: AccessMode | ||
| 6 | let entity: Entity | ||
| 7 | } | ||
| 8 | |||
| 9 | extension 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | protocol 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 | |||
| 9 | private struct RepositoryACLQueryResponse: Decodable, Sendable { | ||
| 10 | let repository: RepositoryACLQueryRepository? | ||
| 11 | } | ||
| 12 | |||
| 13 | private struct RepositoryACLQueryRepository: Decodable, Sendable { | ||
| 14 | let acls: RepositoryACLPage | ||
| 15 | } | ||
| 16 | |||
| 17 | private struct RepositoryACLPage: Decodable, Sendable { | ||
| 18 | let results: [RepositoryACLEntry] | ||
| 19 | } | ||
| 20 | |||
| 21 | private struct RepositoryACLMutationResponse: Decodable, Sendable { | ||
| 22 | let updateACL: RepositoryACLEntry | ||
| 23 | } | ||
| 24 | |||
| 25 | private struct RepositoryACLDeleteResponse: Decodable, Sendable { | ||
| 26 | let deleteACL: RepositoryACLDeletedEntry | ||
| 27 | } | ||
| 28 | |||
| 29 | private struct RepositoryACLDeletedEntry: Decodable, Sendable { | ||
| 30 | let id: Int | ||
| 31 | } | ||
| 32 | |||
| 33 | struct 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 | |||
| 76 | private 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 | |||
| 130 | private 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 | |||
| 183 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final 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 | |||
| 175 | private 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 { | |||
| 9 | @State private var viewModel: RepositoryDetailViewModel? | 9 | @State private var viewModel: RepositoryDetailViewModel? |
| 10 | @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary | 10 | @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary |
| 11 | @State private var showSettings = false | 11 | @State private var showSettings = false |
| 12 | @State private var showACLs = false | ||
| 12 | @State private var displayName: String | 13 | @State private var displayName: String |
| 13 | 14 | ||
| 14 | private var canManageRepository: Bool { | 15 | private var canManageRepository: Bool { |
| @@ -42,6 +43,12 @@ struct RepositoryDetailView: View { | |||
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | if canManageRepository { | 45 | if canManageRepository { |
| 46 | Button { | ||
| 47 | showACLs = true | ||
| 48 | } label: { | ||
| 49 | Image(systemName: "person.2") | ||
| 50 | } | ||
| 51 | |||
| 45 | Button { | 52 | Button { |
| 46 | showSettings = true | 53 | showSettings = true |
| 47 | } label: { | 54 | } label: { |
| @@ -64,6 +71,15 @@ struct RepositoryDetailView: View { | |||
| 64 | } | 71 | } |
| 65 | ) | 72 | ) |
| 66 | } | 73 | } |
| 74 | .sheet(isPresented: $showACLs) { | ||
| 75 | NavigationStack { | ||
| 76 | RepositoryACLView( | ||
| 77 | repository: repository, | ||
| 78 | client: appState.client, | ||
| 79 | showsDoneButton: true | ||
| 80 | ) | ||
| 81 | } | ||
| 82 | } | ||
| 67 | .task { | 83 | .task { |
| 68 | if viewModel == nil { | 84 | if viewModel == nil { |
| 69 | viewModel = RepositoryDetailViewModel( | 85 | viewModel = RepositoryDetailViewModel( |
Hutch/Views/Repositories/RepositorySettingsView.swift +7 −78
| @@ -11,7 +11,6 @@ struct RepositorySettingsView: View { | |||
| 11 | @State private var viewModel: RepositorySettingsViewModel? | 11 | @State private var viewModel: RepositorySettingsViewModel? |
| 12 | @State private var showDeleteConfirmation = false | 12 | @State private var showDeleteConfirmation = false |
| 13 | @State private var showRenameConfirmation = false | 13 | @State private var showRenameConfirmation = false |
| 14 | @State private var pendingACLDeletion: ACLEntry? | ||
| 15 | @State private var saveResultAlert: SaveResultAlert? | 14 | @State private var saveResultAlert: SaveResultAlert? |
| 16 | 15 | ||
| 17 | var body: some View { | 16 | var body: some View { |
| @@ -39,7 +38,6 @@ struct RepositorySettingsView: View { | |||
| 39 | client: client | 38 | client: client |
| 40 | ) | 39 | ) |
| 41 | viewModel = vm | 40 | viewModel = vm |
| 42 | await vm.loadACLs() | ||
| 43 | } | 41 | } |
| 44 | } | 42 | } |
| 45 | } | 43 | } |
| @@ -51,7 +49,7 @@ struct RepositorySettingsView: View { | |||
| 51 | Form { | 49 | Form { |
| 52 | infoSection(viewModel) | 50 | infoSection(viewModel) |
| 53 | renameSection(viewModel) | 51 | renameSection(viewModel) |
| 54 | accessSection(viewModel) | 52 | accessSection() |
| 55 | deleteSection(viewModel) | 53 | deleteSection(viewModel) |
| 56 | } | 54 | } |
| 57 | .srhtErrorBanner(error: $vm.error) | 55 | .srhtErrorBanner(error: $vm.error) |
| @@ -93,29 +91,6 @@ struct RepositorySettingsView: View { | |||
| 93 | } message: { | 91 | } message: { |
| 94 | Text("This cannot be undone.") | 92 | Text("This cannot be undone.") |
| 95 | } | 93 | } |
| 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 | } | ||
| 119 | .alert(item: $saveResultAlert) { alert in | 94 | .alert(item: $saveResultAlert) { alert in |
| 120 | Alert( | 95 | Alert( |
| 121 | title: Text(alert.title), | 96 | title: Text(alert.title), |
| @@ -207,61 +182,15 @@ struct RepositorySettingsView: View { | |||
| 207 | // MARK: - Access Section | 182 | // MARK: - Access Section |
| 208 | 183 | ||
| 209 | @ViewBuilder | 184 | @ViewBuilder |
| 210 | private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View { | 185 | private func accessSection() -> some View { |
| 211 | Section { | 186 | Section { |
| 212 | if viewModel.isLoadingACLs { | 187 | NavigationLink { |
| 213 | HStack { | 188 | RepositoryACLView(repository: repository, client: client, showsDoneButton: false) |
| 214 | Spacer() | 189 | } label: { |
| 215 | ProgressView() | 190 | Label("Manage Access", systemImage: "person.2") |
| 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 | } | ||
| 238 | } | 191 | } |
| 239 | 192 | ||
| 240 | // Add ACL form | 193 | Text("Review and update repository access without leaving settings.") |
| 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.") | ||
| 265 | .font(.caption) | 194 | .font(.caption) |
| 266 | .foregroundStyle(.secondary) | 195 | .foregroundStyle(.secondary) |
| 267 | } header: { | 196 | } header: { |
Hutch/Views/Repositories/RepositorySettingsViewModel.swift −157
| @@ -22,31 +22,6 @@ private struct UpdatedRepoInfo: Decodable, Sendable { | |||
| 22 | let id: Int | 22 | let id: Int |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | private struct ACLResponse: Decodable, Sendable { | ||
| 26 | let repository: ACLRepository? | ||
| 27 | } | ||
| 28 | |||
| 29 | private struct ACLRepository: Decodable, Sendable { | ||
| 30 | let acls: ACLPage | ||
| 31 | } | ||
| 32 | |||
| 33 | private struct ACLPage: Decodable, Sendable { | ||
| 34 | let results: [ACLEntry] | ||
| 35 | let cursor: String? | ||
| 36 | } | ||
| 37 | |||
| 38 | private struct UpdateACLResponse: Decodable, Sendable { | ||
| 39 | let updateACL: ACLEntry | ||
| 40 | } | ||
| 41 | |||
| 42 | private struct DeleteACLResponse: Decodable, Sendable { | ||
| 43 | let deleteACL: DeletedACL | ||
| 44 | } | ||
| 45 | |||
| 46 | private struct DeletedACL: Decodable, Sendable { | ||
| 47 | let id: Int | ||
| 48 | } | ||
| 49 | |||
| 50 | private struct DeleteRepoResponse: Decodable, Sendable { | 25 | private struct DeleteRepoResponse: Decodable, Sendable { |
| 51 | let deleteRepository: DeletedRepo | 26 | let deleteRepository: DeletedRepo |
| 52 | } | 27 | } |
| @@ -55,14 +30,6 @@ private struct DeletedRepo: Decodable, Sendable { | |||
| 55 | let id: Int | 30 | let id: Int |
| 56 | } | 31 | } |
| 57 | 32 | ||
| 58 | // MARK: - ACL Model | ||
| 59 | |||
| 60 | struct ACLEntry: Decodable, Sendable, Identifiable { | ||
| 61 | let id: Int | ||
| 62 | let mode: String | ||
| 63 | let entity: Entity | ||
| 64 | } | ||
| 65 | |||
| 66 | // MARK: - View Model | 33 | // MARK: - View Model |
| 67 | 34 | ||
| 68 | @Observable | 35 | @Observable |
| @@ -87,15 +54,6 @@ final class RepositorySettingsViewModel { | |||
| 87 | var editedName: String | 54 | var editedName: String |
| 88 | var isRenaming = false | 55 | var isRenaming = false |
| 89 | 56 | ||
| 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 | |||
| 99 | // MARK: - Delete state | 57 | // MARK: - Delete state |
| 100 | 58 | ||
| 101 | var isDeleting = false | 59 | var isDeleting = false |
| @@ -202,103 +160,6 @@ final class RepositorySettingsViewModel { | |||
| 202 | } | 160 | } |
| 203 | } | 161 | } |
| 204 | 162 | ||
| 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 | |||
| 302 | static func gitHeadReference(from input: String) -> String { | 163 | static func gitHeadReference(from input: String) -> String { |
| 303 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) | 164 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) |
| 304 | guard !trimmed.isEmpty else { return trimmed } | 165 | guard !trimmed.isEmpty else { return trimmed } |
| @@ -319,24 +180,6 @@ final class RepositorySettingsViewModel { | |||
| 319 | }?.name | 180 | }?.name |
| 320 | } | 181 | } |
| 321 | 182 | ||
| 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 | |||
| 340 | // MARK: - Delete Repository | 183 | // MARK: - Delete Repository |
| 341 | 184 | ||
| 342 | private static let deleteRepoMutation = """ | 185 | private static let deleteRepoMutation = """ |
HutchTests/RepositoryACLViewModelTests.swift added +173
| @@ -0,0 +1,173 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct 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 | ||
| 127 | private 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 { | |||
| 35 | @Test | 35 | @Test |
| 36 | @MainActor | 36 | @MainActor |
| 37 | func gitCanonicalEntityAddsMissingTilde() { | 37 | func gitCanonicalEntityAddsMissingTilde() { |
| 38 | #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice") | 38 | #expect(RepositoryACLViewModel.canonicalEntity(from: "alice") == "~alice") |
| 39 | #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice") | 39 | #expect(RepositoryACLViewModel.canonicalEntity(from: "~alice") == "~alice") |
| 40 | #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice") | 40 | #expect(RepositoryACLViewModel.canonicalEntity(from: " alice ") == "~alice") |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | @Test | 43 | @Test |