diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 22:16:14 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 22:16:14 -0500 |
| commit | 7a875798fea9321bad2636040d9be2c30bc0fa8f (patch) | |
| tree | b0569f6413454f423c4ddaa6b25307bc987ef81d /Hutch/Views/Repositories | |
| parent | 86bcd9452d3107f68aca085e03e69b02667c0dbb (diff) | |
| download | hutch-7a875798fea9321bad2636040d9be2c30bc0fa8f.tar.gz hutch-7a875798fea9321bad2636040d9be2c30bc0fa8f.tar.bz2 hutch-7a875798fea9321bad2636040d9be2c30bc0fa8f.zip | |
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
Diffstat (limited to 'Hutch/Views/Repositories')
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryACLView.swift | 239 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryACLViewModel.swift | 213 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 16 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsView.swift | 85 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 157 |
5 files changed, 475 insertions, 235 deletions
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<Int> = [] + private(set) var deletingEntryIDs: Set<Int> = [] + 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 { @@ -43,6 +44,12 @@ struct RepositoryDetailView: View { if canManageRepository { Button { + showACLs = true + } label: { + Image(systemName: "person.2") + } + + Button { showSettings = true } label: { Image(systemName: "gear") @@ -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 = """ |
