From 7a875798fea9321bad2636040d9be2c30bc0fa8f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 12 Apr 2026 22:16:14 -0500 Subject: 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 --- Hutch.xcodeproj/project.pbxproj | 16 +- Hutch/Models/Git.swift | 2 +- Hutch/Models/RepositoryACL.swift | 20 ++ Hutch/Networking/RepositoryACLService.swift | 106 +++++++++ Hutch/Views/Repositories/RepositoryACLView.swift | 239 +++++++++++++++++++++ .../Repositories/RepositoryACLViewModel.swift | 213 ++++++++++++++++++ .../Views/Repositories/RepositoryDetailView.swift | 16 ++ .../Repositories/RepositorySettingsView.swift | 85 +------- .../Repositories/RepositorySettingsViewModel.swift | 157 -------------- HutchTests/RepositoryACLViewModelTests.swift | 173 +++++++++++++++ HutchTests/RepositorySettingsViewModelTests.swift | 6 +- 11 files changed, 786 insertions(+), 247 deletions(-) create mode 100644 Hutch/Models/RepositoryACL.swift create mode 100644 Hutch/Networking/RepositoryACLService.swift create mode 100644 Hutch/Views/Repositories/RepositoryACLView.swift create mode 100644 Hutch/Views/Repositories/RepositoryACLViewModel.swift create mode 100644 HutchTests/RepositoryACLViewModelTests.swift diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 07c076b..0a9a9b6 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.19.0; + MARKETING_VERSION = 2.19.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.19.0; + MARKETING_VERSION = 2.19.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.19.0; + MARKETING_VERSION = 2.19.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.19.0; + MARKETING_VERSION = 2.19.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift index a45086b..3629ce4 100644 --- a/Hutch/Models/Git.swift +++ b/Hutch/Models/Git.swift @@ -10,7 +10,7 @@ enum Visibility: String, Codable, Sendable { } /// Repository access mode. -enum AccessMode: String, Codable, Sendable { +enum AccessMode: String, Codable, Sendable, CaseIterable { case ro = "RO" case rw = "RW" } diff --git a/Hutch/Models/RepositoryACL.swift b/Hutch/Models/RepositoryACL.swift new file mode 100644 index 0000000..e5f5eb1 --- /dev/null +++ b/Hutch/Models/RepositoryACL.swift @@ -0,0 +1,20 @@ +import Foundation + +struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable { + let id: Int + let mode: AccessMode + let entity: Entity +} + +extension AccessMode { + var shortLabel: String { rawValue } + + var displayName: String { + switch self { + case .ro: + "Read Only" + case .rw: + "Read/Write" + } + } +} diff --git a/Hutch/Networking/RepositoryACLService.swift b/Hutch/Networking/RepositoryACLService.swift new file mode 100644 index 0000000..82a7a38 --- /dev/null +++ b/Hutch/Networking/RepositoryACLService.swift @@ -0,0 +1,106 @@ +import Foundation + +protocol RepositoryACLServicing { + func fetchACLs(repositoryRid: String) async throws -> [RepositoryACLEntry] + func upsertACL(repositoryId: Int, entity: String, mode: AccessMode) async throws -> RepositoryACLEntry + func deleteACL(entryId: Int) async throws +} + +private struct RepositoryACLQueryResponse: Decodable, Sendable { + let repository: RepositoryACLQueryRepository? +} + +private struct RepositoryACLQueryRepository: Decodable, Sendable { + let acls: RepositoryACLPage +} + +private struct RepositoryACLPage: Decodable, Sendable { + let results: [RepositoryACLEntry] +} + +private struct RepositoryACLMutationResponse: Decodable, Sendable { + let updateACL: RepositoryACLEntry +} + +private struct RepositoryACLDeleteResponse: Decodable, Sendable { + let deleteACL: RepositoryACLDeletedEntry +} + +private struct RepositoryACLDeletedEntry: Decodable, Sendable { + let id: Int +} + +struct RepositoryACLService: RepositoryACLServicing { + private let client: SRHTClient + private let service: SRHTService + + init(client: SRHTClient, service: SRHTService) { + self.client = client + self.service = service + } + + func fetchACLs(repositoryRid: String) async throws -> [RepositoryACLEntry] { + let response = try await client.execute( + service: service, + query: Self.aclsQuery, + variables: ["rid": repositoryRid], + responseType: RepositoryACLQueryResponse.self + ) + return response.repository?.acls.results ?? [] + } + + func upsertACL(repositoryId: Int, entity: String, mode: AccessMode) async throws -> RepositoryACLEntry { + let response = try await client.execute( + service: service, + query: Self.upsertACLMutation, + variables: [ + "repoId": repositoryId, + "entity": entity, + "mode": mode.rawValue + ], + responseType: RepositoryACLMutationResponse.self + ) + return response.updateACL + } + + func deleteACL(entryId: Int) async throws { + _ = try await client.execute( + service: service, + query: Self.deleteACLMutation, + variables: ["id": entryId], + responseType: RepositoryACLDeleteResponse.self + ) + } +} + +private extension RepositoryACLService { + static let aclsQuery = """ + query repositoryACLs($rid: ID!) { + repository(rid: $rid) { + acls { + results { + id + mode + entity { canonicalName } + } + } + } + } + """ + + static let upsertACLMutation = """ + mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) { + updateACL(repoId: $repoId, mode: $mode, entity: $entity) { + id + mode + entity { canonicalName } + } + } + """ + + static let deleteACLMutation = """ + mutation deleteACL($id: Int!) { + deleteACL(id: $id) { id } + } + """ +} diff --git a/Hutch/Views/Repositories/RepositoryACLView.swift b/Hutch/Views/Repositories/RepositoryACLView.swift new file mode 100644 index 0000000..0368b4f --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryACLView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +struct RepositoryACLView: View { + let repository: RepositorySummary + let client: SRHTClient + let showsDoneButton: Bool + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: RepositoryACLViewModel? + @State private var pendingDeletion: RepositoryACLEntry? + @State private var showAddSheet = false + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading access…") + } + } + .navigationTitle("Access") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + + if viewModel != nil { + ToolbarItem(placement: .primaryAction) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add User") + } + } + } + .task { + if viewModel == nil { + let service = RepositoryACLService(client: client, service: repository.service) + let vm = RepositoryACLViewModel(repository: repository, service: service) + viewModel = vm + await vm.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: RepositoryACLViewModel) -> some View { + @Bindable var vm = viewModel + + Group { + if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil { + SRHTLoadingStateView(message: "Loading access…") + } else if let loadError = viewModel.loadError, !viewModel.hasEntries { + SRHTErrorStateView( + title: "Couldn't Load Access", + message: loadError, + retryAction: { await viewModel.load() } + ) + } else { + List { + if viewModel.visibleEntries.isEmpty { + ContentUnavailableView { + Label("No Additional Access", systemImage: "person.2.slash") + } description: { + Text("Only the repository owner currently has access.") + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } else { + Section { + ForEach(viewModel.visibleEntries) { entry in + RepositoryACLEntryRow( + entry: entry, + isUpdating: viewModel.isUpdating(entry), + isDeleting: viewModel.isDeleting(entry), + onSelectMode: { mode in + Task { await viewModel.updatePermission(for: entry, to: mode) } + }, + onDelete: { + pendingDeletion = entry + } + ) + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.load() + } + } + } + .srhtErrorBanner(error: $vm.error) + .alert("Remove Access?", isPresented: Binding( + get: { pendingDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Remove Access", role: .destructive) { + guard let entry = pendingDeletion else { return } + Task { + await viewModel.removeEntry(entry) + pendingDeletion = nil + } + } + } message: { + if let entry = pendingDeletion { + Text("\(entry.entity.canonicalName) will lose \(entry.mode.displayName.lowercased()) access to this repository.") + } + } + .sheet(isPresented: $showAddSheet) { + NavigationStack { + RepositoryACLAddUserView(viewModel: viewModel) { + showAddSheet = false + } + } + } + } +} + +private struct RepositoryACLEntryRow: View { + let entry: RepositoryACLEntry + let isUpdating: Bool + let isDeleting: Bool + let onSelectMode: (AccessMode) -> Void + let onDelete: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text(entry.entity.canonicalName) + .font(.body.monospaced()) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + + if isUpdating || isDeleting { + ProgressView() + .controlSize(.small) + } + + Menu { + ForEach(AccessMode.allCases, id: \.self) { mode in + Button { + onSelectMode(mode) + } label: { + if mode == entry.mode { + Label(mode.displayName, systemImage: "checkmark") + } else { + Text(mode.displayName) + } + } + } + } label: { + Text(entry.mode.shortLabel) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary, in: Capsule()) + } + .disabled(isUpdating || isDeleting) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + onDelete() + } label: { + Label("Remove", systemImage: "trash") + } + .disabled(isUpdating || isDeleting) + } + } +} + +private struct RepositoryACLAddUserView: View { + @Environment(\.dismiss) private var dismiss + + @Bindable var viewModel: RepositoryACLViewModel + let onAdded: () -> Void + + var body: some View { + Form { + Section("User") { + TextField("Username or ~username", text: $viewModel.addUsername) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + if let validation = inlineValidationMessage { + Text(validation) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("Permission") { + Picker("Permission", selection: $viewModel.addMode) { + ForEach(AccessMode.allCases, id: \.self) { mode in + Text(mode.shortLabel).tag(mode) + } + } + .pickerStyle(.segmented) + } + } + .navigationTitle("Add User") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + Task { + if await viewModel.addEntry() { + onAdded() + } + } + } + .disabled(!viewModel.canSubmitNewEntry) + } + } + } + + private var inlineValidationMessage: String? { + let trimmed = viewModel.addUsername.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return viewModel.addValidationMessage + } +} diff --git a/Hutch/Views/Repositories/RepositoryACLViewModel.swift b/Hutch/Views/Repositories/RepositoryACLViewModel.swift new file mode 100644 index 0000000..95ca1c4 --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryACLViewModel.swift @@ -0,0 +1,213 @@ +import Foundation + +@Observable +@MainActor +final class RepositoryACLViewModel { + let repository: RepositorySummary + + private let service: any RepositoryACLServicing + + private(set) var entries: [RepositoryACLEntry] = [] + private(set) var isLoading = false + private(set) var loadError: String? + private(set) var updatingEntryIDs: Set = [] + private(set) var deletingEntryIDs: Set = [] + private(set) var isCreatingEntry = false + + var addUsername = "" + var addMode: AccessMode = .ro + var error: String? + + init( + repository: RepositorySummary, + service: any RepositoryACLServicing + ) { + self.repository = repository + self.service = service + } + + var visibleEntries: [RepositoryACLEntry] { + sort(entries.filter { normalizedIdentity($0.entity.canonicalName) != normalizedIdentity(repository.owner.canonicalName) }) + } + + var hasEntries: Bool { + !visibleEntries.isEmpty + } + + var addValidationMessage: String? { + Self.validateEntityInput( + addUsername, + ownerCanonicalName: repository.owner.canonicalName, + existingEntities: visibleEntries.map(\.entity.canonicalName) + ) + } + + var canSubmitNewEntry: Bool { + addValidationMessage == nil && !isCreatingEntry + } + + func load() async { + guard !isLoading else { return } + + isLoading = true + defer { isLoading = false } + + do { + entries = try await service.fetchACLs(repositoryRid: repository.rid) + loadError = nil + } catch { + let message = error.userFacingMessage + if entries.isEmpty { + loadError = message + } else { + self.error = message + } + } + } + + func addEntry() async -> Bool { + guard let entity = validatedNewEntity() else { + error = addValidationMessage ?? "Enter a valid username." + return false + } + + isCreatingEntry = true + defer { isCreatingEntry = false } + error = nil + + do { + let entry = try await service.upsertACL( + repositoryId: repository.id, + entity: entity, + mode: addMode + ) + merge(entry) + addUsername = "" + addMode = .ro + await refreshAfterMutation() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + func updatePermission(for entry: RepositoryACLEntry, to mode: AccessMode) async { + guard entry.mode != mode else { return } + + updatingEntryIDs.insert(entry.id) + defer { updatingEntryIDs.remove(entry.id) } + error = nil + + do { + let updatedEntry = try await service.upsertACL( + repositoryId: repository.id, + entity: entry.entity.canonicalName, + mode: mode + ) + merge(updatedEntry) + await refreshAfterMutation() + } catch { + self.error = error.userFacingMessage + } + } + + func removeEntry(_ entry: RepositoryACLEntry) async { + deletingEntryIDs.insert(entry.id) + defer { deletingEntryIDs.remove(entry.id) } + error = nil + + do { + try await service.deleteACL(entryId: entry.id) + entries.removeAll { $0.id == entry.id } + await refreshAfterMutation() + } catch { + self.error = error.userFacingMessage + } + } + + func isUpdating(_ entry: RepositoryACLEntry) -> Bool { + updatingEntryIDs.contains(entry.id) + } + + func isDeleting(_ entry: RepositoryACLEntry) -> Bool { + deletingEntryIDs.contains(entry.id) + } + + static func canonicalEntity(from input: String) -> String? { + guard validateEntityInput(input, ownerCanonicalName: nil, existingEntities: []) == nil else { + return nil + } + + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + return "~\(username)" + } + + static func validateEntityInput( + _ input: String, + ownerCanonicalName: String?, + existingEntities: [String] + ) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "Enter a username." } + guard !trimmed.contains(where: \.isWhitespace) else { return "Usernames cannot contain spaces." } + + let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + guard !username.isEmpty else { return "Enter a username." } + guard username.first != "~", !username.contains("~") else { return "Enter a valid username." } + guard username.range(of: #"^[A-Za-z0-9][A-Za-z0-9._-]*$"#, options: .regularExpression) != nil else { + return "Enter a valid username." + } + + if let ownerCanonicalName, normalizedIdentity(ownerCanonicalName) == normalizedIdentity(username) { + return "The repository owner already has access." + } + + if existingEntities.contains(where: { normalizedIdentity($0) == normalizedIdentity(username) }) { + return "That user already has access." + } + + return nil + } +} + +private extension RepositoryACLViewModel { + func validatedNewEntity() -> String? { + guard addValidationMessage == nil else { return nil } + return Self.canonicalEntity(from: addUsername) + } + + func refreshAfterMutation() async { + do { + entries = try await service.fetchACLs(repositoryRid: repository.rid) + loadError = nil + } catch { + self.error = "Saved, but couldn't refresh access list: \(error.userFacingMessage)" + } + } + + func merge(_ entry: RepositoryACLEntry) { + if let index = entries.firstIndex(where: { $0.id == entry.id }) { + entries[index] = entry + } else { + entries.append(entry) + } + entries = sort(entries) + } + + func sort(_ entries: [RepositoryACLEntry]) -> [RepositoryACLEntry] { + entries.sorted { + $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending + } + } + + static func normalizedIdentity(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()).lowercased() : trimmed.lowercased() + } + + func normalizedIdentity(_ value: String) -> String { + Self.normalizedIdentity(value) + } +} diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 39ef912..b19e5b0 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -9,6 +9,7 @@ struct RepositoryDetailView: View { @State private var viewModel: RepositoryDetailViewModel? @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary @State private var showSettings = false + @State private var showACLs = false @State private var displayName: String private var canManageRepository: Bool { @@ -42,6 +43,12 @@ struct RepositoryDetailView: View { } if canManageRepository { + Button { + showACLs = true + } label: { + Image(systemName: "person.2") + } + Button { showSettings = true } label: { @@ -64,6 +71,15 @@ struct RepositoryDetailView: View { } ) } + .sheet(isPresented: $showACLs) { + NavigationStack { + RepositoryACLView( + repository: repository, + client: appState.client, + showsDoneButton: true + ) + } + } .task { if viewModel == nil { viewModel = RepositoryDetailViewModel( diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift index 98d3d8a..e6beedf 100644 --- a/Hutch/Views/Repositories/RepositorySettingsView.swift +++ b/Hutch/Views/Repositories/RepositorySettingsView.swift @@ -11,7 +11,6 @@ struct RepositorySettingsView: View { @State private var viewModel: RepositorySettingsViewModel? @State private var showDeleteConfirmation = false @State private var showRenameConfirmation = false - @State private var pendingACLDeletion: ACLEntry? @State private var saveResultAlert: SaveResultAlert? var body: some View { @@ -39,7 +38,6 @@ struct RepositorySettingsView: View { client: client ) viewModel = vm - await vm.loadACLs() } } } @@ -51,7 +49,7 @@ struct RepositorySettingsView: View { Form { infoSection(viewModel) renameSection(viewModel) - accessSection(viewModel) + accessSection() deleteSection(viewModel) } .srhtErrorBanner(error: $vm.error) @@ -93,29 +91,6 @@ struct RepositorySettingsView: View { } message: { Text("This cannot be undone.") } - .alert("Remove Access?", isPresented: Binding( - get: { pendingACLDeletion != nil }, - set: { isPresented in - if !isPresented { - pendingACLDeletion = nil - } - } - )) { - Button("Cancel", role: .cancel) { - // Alert dismissal is implicit; no additional action required. - } - Button("Remove Access", role: .destructive) { - guard let entry = pendingACLDeletion else { return } - Task { - await viewModel.deleteACL(entry) - pendingACLDeletion = nil - } - } - } message: { - if let entry = pendingACLDeletion { - Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") - } - } .alert(item: $saveResultAlert) { alert in Alert( title: Text(alert.title), @@ -207,61 +182,15 @@ struct RepositorySettingsView: View { // MARK: - Access Section @ViewBuilder - private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View { + private func accessSection() -> some View { Section { - if viewModel.isLoadingACLs { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if viewModel.acls.isEmpty { - Text("No access entries yet.") - .foregroundStyle(.secondary) - } else { - ForEach(viewModel.acls) { entry in - HStack { - Text(entry.entity.canonicalName) - Spacer() - Text(entry.mode) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - pendingACLDeletion = entry - } label: { - Label("Remove Access", systemImage: "trash") - } - } - } + NavigationLink { + RepositoryACLView(repository: repository, client: client, showsDoneButton: false) + } label: { + Label("Manage Access", systemImage: "person.2") } - // Add ACL form - HStack { - TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - - Picker("", selection: Bindable(viewModel).newACLMode) { - Text("RO").tag("RO") - Text("RW").tag("RW") - } - .pickerStyle(.segmented) - .frame(width: 100) - - Button { - Task { await viewModel.addACL() } - } label: { - if viewModel.isAddingACL { - ProgressView() - } else { - Text("Add") - } - } - .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - Text("Add a SourceHut user and choose read-only or read/write access.") + Text("Review and update repository access without leaving settings.") .font(.caption) .foregroundStyle(.secondary) } header: { diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index 571ae85..30fe040 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -22,31 +22,6 @@ private struct UpdatedRepoInfo: Decodable, Sendable { let id: Int } -private struct ACLResponse: Decodable, Sendable { - let repository: ACLRepository? -} - -private struct ACLRepository: Decodable, Sendable { - let acls: ACLPage -} - -private struct ACLPage: Decodable, Sendable { - let results: [ACLEntry] - let cursor: String? -} - -private struct UpdateACLResponse: Decodable, Sendable { - let updateACL: ACLEntry -} - -private struct DeleteACLResponse: Decodable, Sendable { - let deleteACL: DeletedACL -} - -private struct DeletedACL: Decodable, Sendable { - let id: Int -} - private struct DeleteRepoResponse: Decodable, Sendable { let deleteRepository: DeletedRepo } @@ -55,14 +30,6 @@ private struct DeletedRepo: Decodable, Sendable { let id: Int } -// MARK: - ACL Model - -struct ACLEntry: Decodable, Sendable, Identifiable { - let id: Int - let mode: String - let entity: Entity -} - // MARK: - View Model @Observable @@ -87,15 +54,6 @@ final class RepositorySettingsViewModel { var editedName: String var isRenaming = false - // MARK: - ACL state - - private(set) var acls: [ACLEntry] = [] - private(set) var isLoadingACLs = false - var newACLEntity = "" - var newACLMode = "RO" - var isAddingACL = false - var isDeletingACL = false - // MARK: - Delete state var isDeleting = false @@ -202,103 +160,6 @@ final class RepositorySettingsViewModel { } } - // MARK: - ACLs - - private static let aclsQuery = """ - query acls($rid: ID!) { - repository(rid: $rid) { - acls { - results { - id - mode - entity { canonicalName } - } - cursor - } - } - } - """ - - private static let updateACLMutation = """ - mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) { - updateACL(repoId: $repoId, mode: $mode, entity: $entity) { - id mode entity { canonicalName } - } - } - """ - - private static let deleteACLMutation = """ - mutation deleteACL($id: Int!) { - deleteACL(id: $id) { id } - } - """ - - private static let userLookupQuery = """ - query userLookup($username: String!) { - user(username: $username) { - id - username - canonicalName - } - } - """ - - func loadACLs() async { - guard !isLoadingACLs else { return } - isLoadingACLs = true - defer { isLoadingACLs = false } - - do { - let result = try await client.execute( - service: service, - query: Self.aclsQuery, - variables: ["rid": repositoryRid], - responseType: ACLResponse.self - ) - acls = result.repository?.acls.results ?? [] - } catch { - self.error = error.userFacingMessage - } - } - - func addACL() async { - let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawEntity.isEmpty else { return } - let entity = Self.gitCanonicalEntity(from: rawEntity) - isAddingACL = true - defer { isAddingACL = false } - error = nil - - do { - let result = try await client.execute( - service: service, - query: Self.updateACLMutation, - variables: [ - "repoId": repositoryId, - "mode": newACLMode, - "entity": entity - ], - responseType: UpdateACLResponse.self - ) - // Replace existing entry or append - if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) { - acls[index] = result.updateACL - } else { - acls.append(result.updateACL) - } - newACLEntity = "" - } catch { - self.error = error.userFacingMessage - } - } - - static func gitCanonicalEntity(from input: String) -> String { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return trimmed } - let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed - return "~\(username)" - } - static func gitHeadReference(from input: String) -> String { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } @@ -319,24 +180,6 @@ final class RepositorySettingsViewModel { }?.name } - func deleteACL(_ entry: ACLEntry) async { - isDeletingACL = true - defer { isDeletingACL = false } - error = nil - - do { - _ = try await client.execute( - service: service, - query: Self.deleteACLMutation, - variables: ["id": entry.id], - responseType: DeleteACLResponse.self - ) - acls.removeAll { $0.id == entry.id } - } catch { - self.error = error.userFacingMessage - } - } - // MARK: - Delete Repository private static let deleteRepoMutation = """ diff --git a/HutchTests/RepositoryACLViewModelTests.swift b/HutchTests/RepositoryACLViewModelTests.swift new file mode 100644 index 0000000..9c3f929 --- /dev/null +++ b/HutchTests/RepositoryACLViewModelTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import Hutch + +struct RepositoryACLViewModelTests { + + @Test + @MainActor + func addValidationRejectsOwnerAndDuplicates() async { + let service = MockRepositoryACLService() + service.fetchResponses = [[ + RepositoryACLEntry( + id: 2, + mode: .ro, + entity: Entity(canonicalName: "~alice") + ) + ]] + let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service) + viewModel.addUsername = "~owner" + #expect(viewModel.addValidationMessage == "The repository owner already has access.") + + await viewModel.load() + viewModel.addUsername = "alice" + #expect(viewModel.addValidationMessage == "That user already has access.") + } + + @Test + @MainActor + func addEntryRefreshesListAfterSuccess() async { + let service = MockRepositoryACLService() + service.fetchResponses = [[ + RepositoryACLEntry( + id: 2, + mode: .rw, + entity: Entity(canonicalName: "~alice") + ) + ]] + + let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service) + viewModel.addUsername = "alice" + viewModel.addMode = .rw + + let didAdd = await viewModel.addEntry() + + #expect(didAdd) + #expect(service.upsertRequests == [MockRepositoryACLService.UpsertRequest(repositoryId: 1, entity: "~alice", mode: .rw)]) + #expect(service.fetchRequestRids == ["rid-1"]) + #expect(viewModel.visibleEntries.map(\.entity.canonicalName) == ["~alice"]) + #expect(viewModel.addUsername.isEmpty) + } + + @Test + @MainActor + func permissionUpdateFailureLeavesExistingEntryUntouched() async { + let service = MockRepositoryACLService() + service.upsertError = SRHTError.httpError(500) + + let entry = RepositoryACLEntry( + id: 2, + mode: .ro, + entity: Entity(canonicalName: "~alice") + ) + let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service) + service.fetchResponses = [[entry]] + await viewModel.load() + + await viewModel.updatePermission(for: entry, to: .rw) + + #expect(viewModel.visibleEntries.first?.mode == .ro) + #expect(viewModel.updatingEntryIDs.isEmpty) + #expect(viewModel.error != nil) + } + + @Test + @MainActor + func removeFailureKeepsEntryVisible() async { + let service = MockRepositoryACLService() + service.deleteError = SRHTError.httpError(500) + + let entry = RepositoryACLEntry( + id: 2, + mode: .ro, + entity: Entity(canonicalName: "~alice") + ) + let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service) + service.fetchResponses = [[entry]] + await viewModel.load() + + await viewModel.removeEntry(entry) + + #expect(viewModel.visibleEntries.map(\.id) == [2]) + #expect(viewModel.deletingEntryIDs.isEmpty) + #expect(viewModel.error != nil) + } + + @Test + @MainActor + func initialLoadFailureSetsBlockingErrorState() async { + let service = MockRepositoryACLService() + service.fetchError = SRHTError.httpError(500) + + let viewModel = RepositoryACLViewModel(repository: makeRepository(), service: service) + + await viewModel.load() + + #expect(viewModel.loadError != nil) + #expect(viewModel.visibleEntries.isEmpty) + } + + @MainActor + private func makeRepository() -> RepositorySummary { + RepositorySummary( + id: 1, + rid: "rid-1", + service: .git, + name: "repo", + description: nil, + visibility: .public, + updated: .now, + owner: Entity(canonicalName: "~owner"), + head: nil + ) + } +} + +@MainActor +private final class MockRepositoryACLService: RepositoryACLServicing { + struct UpsertRequest: Equatable { + let repositoryId: Int + let entity: String + let mode: AccessMode + } + + var fetchResponses: [[RepositoryACLEntry]] = [] + var fetchError: Error? + var upsertResponse = RepositoryACLEntry( + id: 2, + mode: .ro, + entity: Entity(canonicalName: "~alice") + ) + var upsertError: Error? + var deleteError: Error? + + private(set) var fetchRequestRids: [String] = [] + private(set) var upsertRequests: [UpsertRequest] = [] + private(set) var deleteRequestIDs: [Int] = [] + + func fetchACLs(repositoryRid: String) async throws -> [RepositoryACLEntry] { + fetchRequestRids.append(repositoryRid) + if let fetchError { + throw fetchError + } + if !fetchResponses.isEmpty { + return fetchResponses.removeFirst() + } + return [] + } + + func upsertACL(repositoryId: Int, entity: String, mode: AccessMode) async throws -> RepositoryACLEntry { + upsertRequests.append(UpsertRequest(repositoryId: repositoryId, entity: entity, mode: mode)) + if let upsertError { + throw upsertError + } + return RepositoryACLEntry(id: upsertResponse.id, mode: mode, entity: Entity(canonicalName: entity)) + } + + func deleteACL(entryId: Int) async throws { + deleteRequestIDs.append(entryId) + if let deleteError { + throw deleteError + } + } +} diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift index f211bab..5b06539 100644 --- a/HutchTests/RepositorySettingsViewModelTests.swift +++ b/HutchTests/RepositorySettingsViewModelTests.swift @@ -35,9 +35,9 @@ struct RepositorySettingsViewModelTests { @Test @MainActor func gitCanonicalEntityAddsMissingTilde() { - #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice") - #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice") - #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice") + #expect(RepositoryACLViewModel.canonicalEntity(from: "alice") == "~alice") + #expect(RepositoryACLViewModel.canonicalEntity(from: "~alice") == "~alice") + #expect(RepositoryACLViewModel.canonicalEntity(from: " alice ") == "~alice") } @Test -- cgit v1.2.3