krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

7a875798fea9321bad2636040d9be2c30bc0fa8f

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T03:16:14Z

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(-)

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<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 {
@@ -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