krz/hutch

an ios client for sourcehut

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

902055d5eaa7f5512b76e22f57df3939f956181f

verified · cmc

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

Add tracker management (delete/update, ACLs, labels)

- Add swipe actions (delete/edit) to tracker list
- Add tracker settings menu on detail screen
- Implement updateTracker and deleteTracker mutations
- Implement ACL management (updateUserACL, updateTrackerACL, deleteACL)
- Implement label management (createLabel, updateLabel, deleteLabel)
- Add dedicated UI flows for ACLs and labels
- Ensure state refresh after mutations
- Handle loading, errors, and destructive confirmations

Implements: https://todo.sr.ht/~ccleberg/Hutch/12
 Hutch.xcodeproj/project.pbxproj                  |   16 +-
 Hutch/Models/Todo.swift                          |   49 +
 Hutch/Views/Tickets/TicketDetailViewModel.swift  |  124 +--
 Hutch/Views/Tickets/TicketListView.swift         |  142 ++-
 Hutch/Views/Tickets/TicketListViewModel.swift    |   60 +-
 Hutch/Views/Tickets/TrackerListView.swift        |  199 ++--
 Hutch/Views/Tickets/TrackerListViewModel.swift   |  112 ++
 Hutch/Views/Tickets/TrackerManagementView.swift  | 1268 ++++++++++++++++++++++
 HutchTests/TrackerManagementViewModelTests.swift |   36 +
 9 files changed, 1783 insertions(+), 223 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index d844cd3..bf79a48 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -495,7 +495,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 16;
+				CURRENT_PROJECT_VERSION = 17;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -512,7 +512,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.6.1;
+				MARKETING_VERSION = 2.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -532,7 +532,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 16;
+				CURRENT_PROJECT_VERSION = 17;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -549,7 +549,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.6.1;
+				MARKETING_VERSION = 2.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -612,7 +612,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 16;
+				CURRENT_PROJECT_VERSION = 17;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -622,7 +622,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.6.1;
+				MARKETING_VERSION = 2.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -641,7 +641,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 16;
+				CURRENT_PROJECT_VERSION = 17;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -651,7 +651,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.6.1;
+				MARKETING_VERSION = 2.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift
index 8a2b06c..129d4bf 100644
--- a/Hutch/Models/Todo.swift
+++ b/Hutch/Models/Todo.swift
@@ -81,6 +81,55 @@ struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
     let foregroundColor: String
 }
 
+// MARK: - Tracker ACL
+
+struct TrackerACLPermissions: Codable, Sendable, Hashable {
+    let browse: Bool
+    let submit: Bool
+    let comment: Bool
+    let edit: Bool
+    let triage: Bool
+}
+
+struct TrackerACL: Codable, Sendable, Identifiable, Hashable {
+    let id: Int
+    let created: Date
+    let entity: Entity
+    let browse: Bool
+    let submit: Bool
+    let comment: Bool
+    let edit: Bool
+    let triage: Bool
+
+    var permissions: TrackerACLPermissions {
+        TrackerACLPermissions(
+            browse: browse,
+            submit: submit,
+            comment: comment,
+            edit: edit,
+            triage: triage
+        )
+    }
+}
+
+struct DefaultTrackerACL: Codable, Sendable, Hashable {
+    let browse: Bool
+    let submit: Bool
+    let comment: Bool
+    let edit: Bool
+    let triage: Bool
+
+    var permissions: TrackerACLPermissions {
+        TrackerACLPermissions(
+            browse: browse,
+            submit: submit,
+            comment: comment,
+            edit: edit,
+            triage: triage
+        )
+    }
+}
+
 // MARK: - Tracker
 
 /// A bug tracker from todo.sr.ht.
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 142c62b..999374b 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -3,10 +3,6 @@ import Foundation
 // MARK: - Response types (file-private to avoid @MainActor Decodable issues)
 
 private struct TicketDetailResponse: Decodable, Sendable {
-    let user: UserTrackerTicketWrapper
-}
-
-private struct UserTrackerTicketWrapper: Decodable, Sendable {
     let tracker: TrackerTicketWrapper
 }
 
@@ -85,10 +81,6 @@ private struct CreateLabelResponse: Decodable, Sendable {
 }
 
 private struct TrackerLabelsResponse: Decodable, Sendable {
-    let user: UserTrackerLabelsWrapper
-}
-
-private struct UserTrackerLabelsWrapper: Decodable, Sendable {
     let tracker: TrackerLabelsWrapper
 }
 
@@ -155,57 +147,55 @@ final class TicketDetailViewModel {
     // MARK: - Queries
 
     private static let detailQuery = """
-    query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
-        user(username: $owner) {
-            tracker(name: $tracker) {
-                ticket(id: $ticketId) {
-                    id
-                    created
-                    updated
-                    title: subject
-                    description: body
-                    status
-                    resolution
-                    authenticity
-                    submitter { canonicalName }
-                    assignees { canonicalName }
-                    labels { id name backgroundColor foregroundColor }
-                    events {
-                        results {
-                            id
-                            created
-                            changes {
-                                eventType
-                                ... on Comment {
-                                    author { canonicalName }
-                                    text
-                                    authenticity
-                                }
-                                ... on StatusChange {
-                                    oldStatus
-                                    newStatus
-                                }
-                                ... on LabelUpdate {
-                                    labeler { canonicalName }
-                                    label { name }
-                                }
-                                ... on Assignment {
-                                    assigner { canonicalName }
-                                    assignee { canonicalName }
-                                }
-                                ... on TicketMention {
-                                    mentioned { id }
-                                }
-                                ... on UserMention {
-                                    mentioned { canonicalName }
-                                }
-                                ... on Created {
-                                    author { canonicalName }
-                                }
+    query ticket($rid: ID!, $ticketId: Int!) {
+        tracker(rid: $rid) {
+            ticket(id: $ticketId) {
+                id
+                created
+                updated
+                title: subject
+                description: body
+                status
+                resolution
+                authenticity
+                submitter { canonicalName }
+                assignees { canonicalName }
+                labels { id name backgroundColor foregroundColor }
+                events {
+                    results {
+                        id
+                        created
+                        changes {
+                            eventType
+                            ... on Comment {
+                                author { canonicalName }
+                                text
+                                authenticity
+                            }
+                            ... on StatusChange {
+                                oldStatus
+                                newStatus
+                            }
+                            ... on LabelUpdate {
+                                labeler { canonicalName }
+                                label { name }
+                            }
+                            ... on Assignment {
+                                assigner { canonicalName }
+                                assignee { canonicalName }
+                            }
+                            ... on TicketMention {
+                                mentioned { id }
+                            }
+                            ... on UserMention {
+                                mentioned { canonicalName }
+                            }
+                            ... on Created {
+                                author { canonicalName }
                             }
                         }
-                        cursor
                     }
+                    cursor
                 }
             }
         }
@@ -268,12 +258,10 @@ final class TicketDetailViewModel {
     """
 
     private static let trackerLabelsQuery = """
-    query trackerLabels($owner: String!, $tracker: String!) {
-        user(username: $owner) {
-            tracker(name: $tracker) {
-                labels {
-                    results { id name backgroundColor foregroundColor }
-                }
+    query trackerLabels($rid: ID!) {
+        tracker(rid: $rid) {
+            labels {
+                results { id name backgroundColor foregroundColor }
             }
         }
     }
@@ -302,13 +290,12 @@ final class TicketDetailViewModel {
                 service: .todo,
                 query: Self.detailQuery,
                 variables: [
-                    "owner": ownerUsername,
-                    "tracker": trackerName,
+                    "rid": trackerRid,
                     "ticketId": ticketId
                 ],
                 responseType: TicketDetailResponse.self
             )
-            let payload = result.user.tracker.ticket
+            let payload = result.tracker.ticket
             ticket = TicketDetail(
                 id: payload.id,
                 created: payload.created,
@@ -579,13 +566,10 @@ final class TicketDetailViewModel {
             let result = try await client.execute(
                 service: .todo,
                 query: Self.trackerLabelsQuery,
-                variables: [
-                    "owner": ownerUsername,
-                    "tracker": trackerName
-                ],
+                variables: ["rid": trackerRid],
                 responseType: TrackerLabelsResponse.self
             )
-            trackerLabels = result.user.tracker.labels.results
+            trackerLabels = result.tracker.labels.results
         } catch {
             self.error = error.userFacingMessage
         }
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index 69ab419..baed21c 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -1,17 +1,32 @@
 import SwiftUI
 
 struct TicketListView: View {
-    let ownerUsername: String
-    let trackerName: String
-    let trackerId: Int
-    let trackerRid: String
+    let onTrackerUpdated: (TrackerSummary) -> Void
+    let onTrackerDeleted: (TrackerSummary) -> Void
 
     @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
     @Environment(AppState.self) private var appState
+    @Environment(\.dismiss) private var dismiss
+    @State private var tracker: TrackerSummary
     @State private var viewModel: TicketListViewModel?
+    @State private var trackerManagementViewModel: TrackerManagementViewModel?
     @State private var showCreateTicketSheet = false
     @State private var createdTicket: TicketSummary?
     @State private var labelEditorTicket: LabelEditorTicket?
+    @State private var showTrackerEditor = false
+    @State private var showTrackerACLs = false
+    @State private var showTrackerLabels = false
+    @State private var showDeleteTrackerConfirmation = false
+
+    init(
+        tracker: TrackerSummary,
+        onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in },
+        onTrackerDeleted: @escaping (TrackerSummary) -> Void = { _ in }
+    ) {
+        self._tracker = State(initialValue: tracker)
+        self.onTrackerUpdated = onTrackerUpdated
+        self.onTrackerDeleted = onTrackerDeleted
+    }
 
     var body: some View {
         Group {
@@ -21,11 +36,17 @@ struct TicketListView: View {
                 SRHTLoadingStateView(message: "Loading tickets…")
             }
         }
-        .navigationTitle(trackerName)
+        .navigationTitle(tracker.name)
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
             ToolbarItemGroup(placement: .topBarTrailing) {
-                SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
+                SRHTShareButton(
+                    url: SRHTWebURL.tracker(
+                        ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                        trackerName: tracker.name
+                    ),
+                    target: .tracker
+                ) {
                     Image(systemName: "square.and.arrow.up")
                 }
 
@@ -36,6 +57,8 @@ struct TicketListView: View {
                         Image(systemName: "plus")
                     }
                     .accessibilityLabel("Create ticket")
+
+                    trackerActionsMenu
                 }
             }
         }
@@ -56,6 +79,57 @@ struct TicketListView: View {
                 .presentationDetents([.medium])
             }
         }
+        .sheet(isPresented: $showTrackerEditor) {
+            if let trackerManagementViewModel {
+                TrackerEditorSheet(
+                    title: "Update Tracker",
+                    confirmationTitle: "Save",
+                    isSaving: trackerManagementViewModel.isSavingTracker,
+                    error: trackerManagementViewModel.error,
+                    initialName: tracker.name,
+                    initialDescription: tracker.description ?? "",
+                    initialVisibility: tracker.visibility
+                ) { name, description, visibility in
+                    if let updatedTracker = await trackerManagementViewModel.updateTracker(
+                        name: name,
+                        description: description,
+                        visibility: visibility
+                    ) {
+                        tracker = updatedTracker
+                        onTrackerUpdated(updatedTracker)
+                        return true
+                    }
+                    return false
+                }
+            }
+        }
+        .sheet(isPresented: $showTrackerACLs) {
+            if let trackerManagementViewModel {
+                TrackerACLManagementSheet(viewModel: trackerManagementViewModel)
+                    .presentationDetents([.large])
+            }
+        }
+        .sheet(isPresented: $showTrackerLabels) {
+            if let trackerManagementViewModel {
+                TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
+                    .presentationDetents([.large])
+            }
+        }
+        .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
+            Button("Cancel", role: .cancel) {}
+            Button("Delete", role: .destructive) {
+                guard let trackerManagementViewModel else { return }
+                Task {
+                    let didDelete = await trackerManagementViewModel.deleteTracker()
+                    if didDelete {
+                        onTrackerDeleted(tracker)
+                        dismiss()
+                    }
+                }
+            }
+        } message: {
+            Text("“\(tracker.name)” will be permanently deleted.")
+        }
         .navigationDestination(isPresented: Binding(
             get: { createdTicket != nil },
             set: { isPresented in
@@ -65,19 +139,26 @@ struct TicketListView: View {
             }
         )) {
             if let createdTicket {
-                TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
+                TicketDetailView(
+                    ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                    trackerName: tracker.name,
+                    trackerId: tracker.id,
+                    trackerRid: tracker.rid,
+                    ticketId: createdTicket.id
+                )
             }
         }
         .task {
             if viewModel == nil {
                 let vm = TicketListViewModel(
-                    ownerUsername: ownerUsername,
-                    trackerName: trackerName,
-                    trackerId: trackerId,
-                    trackerRid: trackerRid,
+                    ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                    trackerName: tracker.name,
+                    trackerId: tracker.id,
+                    trackerRid: tracker.rid,
                     client: appState.client
                 )
                 viewModel = vm
+                trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
                 await vm.loadTickets()
             }
         }
@@ -163,8 +244,45 @@ struct TicketListView: View {
             await viewModel.loadTickets()
         }
         .navigationDestination(for: TicketSummary.self) { ticket in
-            TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
+            TicketDetailView(
+                ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                trackerName: tracker.name,
+                trackerId: tracker.id,
+                trackerRid: tracker.rid,
+                ticketId: ticket.id
+            )
+        }
+    }
+
+    private var trackerActionsMenu: some View {
+        Menu {
+            Button {
+                showTrackerEditor = true
+            } label: {
+                Label("Update Tracker", systemImage: "pencil")
+            }
+
+            Button {
+                showTrackerACLs = true
+            } label: {
+                Label("Manage ACLs", systemImage: "person.2")
+            }
+
+            Button {
+                showTrackerLabels = true
+            } label: {
+                Label("Manage Labels", systemImage: "tag")
+            }
+
+            Button(role: .destructive) {
+                showDeleteTrackerConfirmation = true
+            } label: {
+                Label("Delete Tracker", systemImage: "trash")
+            }
+        } label: {
+            Image(systemName: "ellipsis.circle")
         }
+        .accessibilityLabel("Tracker actions")
     }
 
     @ViewBuilder
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 4a9f8df..9f2fe79 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -3,10 +3,6 @@ import Foundation
 // MARK: - Response types (file-private to avoid @MainActor Decodable issues)
 
 private struct TrackerTicketsResponse: Decodable, Sendable {
-    let user: UserTrackerWrapper
-}
-
-private struct UserTrackerWrapper: Decodable, Sendable {
     let tracker: TrackerTicketsWrapper
 }
 
@@ -38,10 +34,6 @@ private struct LabelMutationResponse: Decodable, Sendable {
 }
 
 private struct TrackerLabelsResponse: Decodable, Sendable {
-    let user: UserTrackerLabelsWrapper
-}
-
-private struct UserTrackerLabelsWrapper: Decodable, Sendable {
     let tracker: TrackerLabelsWrapper
 }
 
@@ -116,22 +108,20 @@ final class TicketListViewModel {
     // MARK: - Query
 
     private static let query = """
-    query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
-        user(username: $owner) {
-            tracker(name: $tracker) {
-                tickets(cursor: $cursor) {
-                    results {
-                        id
-                        title: subject
-                        status
-                        resolution
-                        created
-                        submitter { canonicalName }
-                        labels { id name backgroundColor foregroundColor }
-                        assignees { canonicalName }
-                    }
-                    cursor
+    query tickets($rid: ID!, $cursor: Cursor) {
+        tracker(rid: $rid) {
+            tickets(cursor: $cursor) {
+                results {
+                    id
+                    title: subject
+                    status
+                    resolution
+                    created
+                    submitter { canonicalName }
+                    labels { id name backgroundColor foregroundColor }
+                    assignees { canonicalName }
                 }
+                cursor
             }
         }
     }
@@ -185,12 +175,10 @@ final class TicketListViewModel {
     """
 
     private static let trackerLabelsQuery = """
-    query trackerLabels($owner: String!, $tracker: String!) {
-        user(username: $owner) {
-            tracker(name: $tracker) {
-                labels {
-                    results { id name backgroundColor foregroundColor }
-                }
+    query trackerLabels($rid: ID!) {
+        tracker(rid: $rid) {
+            labels {
+                results { id name backgroundColor foregroundColor }
             }
         }
     }
@@ -404,13 +392,10 @@ final class TicketListViewModel {
             let result = try await client.execute(
                 service: .todo,
                 query: Self.trackerLabelsQuery,
-                variables: [
-                    "owner": ownerUsername,
-                    "tracker": trackerName
-                ],
+                variables: ["rid": trackerRid],
                 responseType: TrackerLabelsResponse.self
             )
-            trackerLabels = result.user.tracker.labels.results
+            trackerLabels = result.tracker.labels.results
         } catch {
             self.error = error.userFacingMessage
         }
@@ -530,10 +515,7 @@ final class TicketListViewModel {
     }
 
     private func fetchPage(cursor: String?) async throws -> TicketsPage {
-        var variables: [String: any Sendable] = [
-            "owner": ownerUsername,
-            "tracker": trackerName
-        ]
+        var variables: [String: any Sendable] = ["rid": trackerRid]
         if let cursor {
             variables["cursor"] = cursor
         }
@@ -543,7 +525,7 @@ final class TicketListViewModel {
             variables: variables,
             responseType: TrackerTicketsResponse.self
         )
-        return result.user.tracker.tickets
+        return result.tracker.tickets
     }
 
     private struct SubmitTicketResponse: Decodable, Sendable {
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index a324eae..ac539df 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -5,6 +5,8 @@ struct TrackerListView: View {
     @State private var viewModel: TrackerListViewModel?
     @State private var showCreateTrackerSheet = false
     @State private var createdTracker: TrackerSummary?
+    @State private var editingTracker: TrackerSummary?
+    @State private var pendingDeletion: TrackerSummary?
 
     var body: some View {
         Group {
@@ -28,9 +30,50 @@ struct TrackerListView: View {
         }
         .sheet(isPresented: $showCreateTrackerSheet) {
             if let viewModel {
-                CreateTrackerSheet(viewModel: viewModel) { tracker in
-                    showCreateTrackerSheet = false
-                    createdTracker = tracker
+                TrackerEditorSheet(
+                    title: "New Tracker",
+                    confirmationTitle: "Create Tracker",
+                    isSaving: viewModel.isCreatingTracker,
+                    error: viewModel.error,
+                    initialName: "",
+                    initialDescription: "",
+                    initialVisibility: .public
+                ) { name, description, visibility in
+                    if let tracker = await viewModel.createTracker(
+                        name: name,
+                        description: description,
+                        visibility: visibility
+                    ) {
+                        createdTracker = tracker
+                        showCreateTrackerSheet = false
+                        return true
+                    }
+                    return false
+                }
+            }
+        }
+        .sheet(item: $editingTracker) { tracker in
+            if let viewModel {
+                TrackerEditorSheet(
+                    title: "Update Tracker",
+                    confirmationTitle: "Save",
+                    isSaving: viewModel.isCreatingTracker,
+                    error: viewModel.error,
+                    initialName: tracker.name,
+                    initialDescription: tracker.description ?? "",
+                    initialVisibility: tracker.visibility
+                ) { name, description, visibility in
+                    if let updatedTracker = await viewModel.updateTracker(
+                        tracker,
+                        name: name,
+                        description: description,
+                        visibility: visibility
+                    ) {
+                        createdTracker = createdTracker?.id == tracker.id ? updatedTracker : createdTracker
+                        editingTracker = nil
+                        return true
+                    }
+                    return false
                 }
             }
         }
@@ -43,12 +86,42 @@ struct TrackerListView: View {
             }
         )) {
             if let createdTracker {
-                TicketListView(
-                    ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
-                    trackerName: createdTracker.name,
-                    trackerId: createdTracker.id,
-                    trackerRid: createdTracker.rid
-                )
+                TicketListView(tracker: createdTracker) { updatedTracker in
+                    viewModel?.applyTrackerUpdate(updatedTracker)
+                    self.createdTracker = updatedTracker
+                } onTrackerDeleted: { deletedTracker in
+                    viewModel?.applyTrackerDeletion(deletedTracker)
+                    if let viewModel {
+                        Task { await viewModel.loadTrackers() }
+                    }
+                    self.createdTracker = nil
+                }
+            }
+        }
+        .alert("Delete Tracker?", isPresented: Binding(
+            get: { pendingDeletion != nil },
+            set: { isPresented in
+                if !isPresented {
+                    pendingDeletion = nil
+                }
+            }
+        )) {
+            Button("Cancel", role: .cancel) {}
+            Button("Delete", role: .destructive) {
+                guard let pendingDeletion, let viewModel else { return }
+                Task {
+                    let didDelete = await viewModel.deleteTracker(pendingDeletion)
+                    if didDelete {
+                        if createdTracker?.id == pendingDeletion.id {
+                            createdTracker = nil
+                        }
+                        self.pendingDeletion = nil
+                    }
+                }
+            }
+        } message: {
+            if let pendingDeletion {
+                Text("“\(pendingDeletion.name)” will be permanently deleted.")
             }
         }
         .task {
@@ -69,6 +142,20 @@ struct TrackerListView: View {
                 NavigationLink(value: tracker) {
                     TrackerRowView(tracker: tracker)
                 }
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    Button(role: .destructive) {
+                        pendingDeletion = tracker
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+
+                    Button {
+                        editingTracker = tracker
+                    } label: {
+                        Label("Edit", systemImage: "pencil")
+                    }
+                    .tint(.blue)
+                }
                 .task {
                     await viewModel.loadMoreIfNeeded(currentItem: tracker)
                 }
@@ -116,92 +203,16 @@ struct TrackerListView: View {
             await viewModel.loadTrackers()
         }
         .navigationDestination(for: TrackerSummary.self) { tracker in
-            TicketListView(
-                ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
-                trackerName: tracker.name,
-                trackerId: tracker.id,
-                trackerRid: tracker.rid
-            )
-        }
-    }
-}
-
-private struct CreateTrackerSheet: View {
-    let viewModel: TrackerListViewModel
-    let onCreated: (TrackerSummary) -> Void
-
-    @Environment(\.dismiss) private var dismiss
-    @Bindable var viewModelBindable: TrackerListViewModel
-    @State private var name = ""
-    @State private var description = ""
-    @State private var visibility: Visibility = .public
-
-    init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
-        self.viewModel = viewModel
-        self._viewModelBindable = Bindable(viewModel)
-        self.onCreated = onCreated
-    }
-
-    var body: some View {
-        NavigationStack {
-            Form {
-                Section("Tracker Details") {
-                    TextField("Tracker name", text: $name)
-                        .textInputAutocapitalization(.never)
-                        .autocorrectionDisabled()
-                    TextField("Short description (optional)", text: $description, axis: .vertical)
-                        .lineLimit(2...4)
-                    Picker("Visibility", selection: $visibility) {
-                        Text("Public").tag(Visibility.public)
-                        Text("Unlisted").tag(Visibility.unlisted)
-                        Text("Private").tag(Visibility.private)
-                    }
+            TicketListView(tracker: tracker) { updatedTracker in
+                viewModel.applyTrackerUpdate(updatedTracker)
+                if createdTracker?.id == updatedTracker.id {
+                    createdTracker = updatedTracker
                 }
-
-                if let error = viewModel.error {
-                    Section {
-                        Label {
-                            Text(error)
-                        } icon: {
-                            Image(systemName: "exclamationmark.triangle.fill")
-                                .foregroundStyle(.red)
-                        }
-                        .foregroundStyle(.red)
-                    }
-                }
-            }
-            .navigationTitle("New Tracker")
-            .navigationBarTitleDisplayMode(.inline)
-            .onDisappear {
-                viewModelBindable.error = nil
-            }
-            .toolbar {
-                ToolbarItem(placement: .cancellationAction) {
-                    Button("Cancel") {
-                        viewModelBindable.error = nil
-                        dismiss()
-                    }
-                }
-                ToolbarItem(placement: .confirmationAction) {
-                    Button {
-                        Task {
-                            if let tracker = await viewModel.createTracker(
-                                name: name,
-                                description: description,
-                                visibility: visibility
-                            ) {
-                                onCreated(tracker)
-                            }
-                        }
-                    } label: {
-                        if viewModel.isCreatingTracker {
-                            ProgressView()
-                                .controlSize(.small)
-                        } else {
-                            Text("Create Tracker")
-                        }
-                    }
-                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
+            } onTrackerDeleted: { deletedTracker in
+                viewModel.applyTrackerDeletion(deletedTracker)
+                Task { await viewModel.loadTrackers() }
+                if createdTracker?.id == deletedTracker.id {
+                    createdTracker = nil
                 }
             }
         }
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
index 31c07d7..f18bb7b 100644
--- a/Hutch/Views/Tickets/TrackerListViewModel.swift
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -11,6 +11,18 @@ private struct TrackersPage: Decodable, Sendable {
     let cursor: String?
 }
 
+private struct UpdateTrackerResponse: Decodable, Sendable {
+    let updateTracker: TrackerSummary
+}
+
+private struct DeleteTrackerResponse: Decodable, Sendable {
+    let deleteTracker: DeletedTracker
+}
+
+private struct DeletedTracker: Decodable, Sendable {
+    let id: Int
+}
+
 // MARK: - View Model
 
 @Observable
@@ -75,6 +87,28 @@ final class TrackerListViewModel {
     }
     """
 
+    private static let updateTrackerMutation = """
+    mutation updateTracker($id: Int!, $input: TrackerInput!) {
+        updateTracker(id: $id, input: $input) {
+            id
+            rid
+            name
+            description
+            visibility
+            updated
+            owner { canonicalName }
+        }
+    }
+    """
+
+    private static let deleteTrackerMutation = """
+    mutation deleteTracker($id: Int!) {
+        deleteTracker(id: $id) {
+            id
+        }
+    }
+    """
+
     // MARK: - Public API
 
     func loadTrackers() async {
@@ -155,6 +189,84 @@ final class TrackerListViewModel {
         }
     }
 
+    func updateTracker(
+        _ tracker: TrackerSummary,
+        name: String,
+        description: String,
+        visibility: Visibility
+    ) async -> TrackerSummary? {
+        guard !isCreatingTracker else { return nil }
+
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else {
+            error = "Enter a tracker name."
+            return nil
+        }
+
+        isCreatingTracker = true
+        error = nil
+        defer { isCreatingTracker = false }
+
+        let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+        let input: [String: any Sendable] = [
+            "name": trimmedName,
+            "description": trimmedDescription.isEmpty ? "" : trimmedDescription,
+            "visibility": visibility.rawValue
+        ]
+
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.updateTrackerMutation,
+                variables: [
+                    "id": tracker.id,
+                    "input": input
+                ],
+                responseType: UpdateTrackerResponse.self
+            )
+            applyTrackerUpdate(result.updateTracker)
+            return result.updateTracker
+        } catch {
+            self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
+            return nil
+        }
+    }
+
+    func deleteTracker(_ tracker: TrackerSummary) async -> Bool {
+        guard !isCreatingTracker else { return false }
+
+        isCreatingTracker = true
+        error = nil
+        defer { isCreatingTracker = false }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.deleteTrackerMutation,
+                variables: ["id": tracker.id],
+                responseType: DeleteTrackerResponse.self
+            )
+            trackers.removeAll { $0.id == tracker.id }
+            await loadTrackers()
+            return true
+        } catch {
+            self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
+            return false
+        }
+    }
+
+    func applyTrackerUpdate(_ tracker: TrackerSummary) {
+        if let index = trackers.firstIndex(where: { $0.id == tracker.id }) {
+            trackers[index] = tracker
+        } else {
+            trackers.insert(tracker, at: 0)
+        }
+    }
+
+    func applyTrackerDeletion(_ tracker: TrackerSummary) {
+        trackers.removeAll { $0.id == tracker.id }
+    }
+
     // MARK: - Private
 
     private func fetchPage(cursor: String?) async throws -> TrackersPage {
diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift
new file mode 100644
index 0000000..f07c878
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerManagementView.swift
@@ -0,0 +1,1268 @@
+import SwiftUI
+
+private struct TrackerQueryResponse: Decodable, Sendable {
+    let tracker: TrackerSummary?
+}
+
+private struct TrackerACLQueryResponse: Decodable, Sendable {
+    let tracker: TrackerACLQueryPayload?
+}
+
+private struct TrackerACLQueryPayload: Decodable, Sendable {
+    let defaultACL: DefaultTrackerACL
+    let acls: TrackerACLPage
+}
+
+private struct TrackerACLPage: Decodable, Sendable {
+    let results: [TrackerACL]
+    let cursor: String?
+}
+
+private struct TrackerLabelQueryResponse: Decodable, Sendable {
+    let tracker: TrackerLabelQueryPayload?
+}
+
+private struct TrackerLabelQueryPayload: Decodable, Sendable {
+    let labels: TrackerLabelPage
+}
+
+private struct TrackerLabelPage: Decodable, Sendable {
+    let results: [TicketLabel]
+    let cursor: String?
+}
+
+private struct UpdateTrackerResponse: Decodable, Sendable {
+    let updateTracker: TrackerSummary
+}
+
+private struct DeleteTrackerResponse: Decodable, Sendable {
+    let deleteTracker: DeletedTracker
+}
+
+private struct DeletedTracker: Decodable, Sendable {
+    let id: Int
+}
+
+private struct UpdateUserACLResponse: Decodable, Sendable {
+    let updateUserACL: TrackerACL
+}
+
+private struct UpdateTrackerACLResponse: Decodable, Sendable {
+    let updateTrackerACL: DefaultTrackerACL
+}
+
+private struct DeleteTrackerACLResponse: Decodable, Sendable {
+    let deleteACL: TrackerACL
+}
+
+private struct CreateTrackerLabelResponse: Decodable, Sendable {
+    let createLabel: TicketLabel
+}
+
+private struct UpdateTrackerLabelResponse: Decodable, Sendable {
+    let updateLabel: TicketLabel
+}
+
+private struct DeleteTrackerLabelResponse: Decodable, Sendable {
+    let deleteLabel: TicketLabel
+}
+
+private struct TrackerUserLookupResponse: Decodable, Sendable {
+    let user: UserIdPayload?
+}
+
+private struct UserIdPayload: Decodable, Sendable {
+    let id: Int
+}
+
+@Observable
+@MainActor
+final class TrackerManagementViewModel {
+    private(set) var tracker: TrackerSummary
+    private(set) var acls: [TrackerACL] = []
+    private(set) var defaultACL = DefaultTrackerACL(
+        browse: true,
+        submit: true,
+        comment: true,
+        edit: false,
+        triage: false
+    )
+    private(set) var labels: [TicketLabel] = []
+
+    private(set) var isSavingTracker = false
+    private(set) var isDeletingTracker = false
+    private(set) var isLoadingACLs = false
+    private(set) var isSavingACL = false
+    private(set) var isDeletingACL = false
+    private(set) var isLoadingLabels = false
+    private(set) var isSavingLabel = false
+    private(set) var isDeletingLabel = false
+
+    var error: String?
+    var didDeleteTracker = false
+
+    private let client: SRHTClient
+
+    init(tracker: TrackerSummary, client: SRHTClient) {
+        self.tracker = tracker
+        self.client = client
+    }
+
+    private static let trackerQuery = """
+    query tracker($rid: ID!) {
+        tracker(rid: $rid) {
+            id
+            rid
+            name
+            description
+            visibility
+            updated
+            owner { canonicalName }
+        }
+    }
+    """
+
+    private static let trackerACLsQuery = """
+    query trackerACLs($rid: ID!, $cursor: Cursor) {
+        tracker(rid: $rid) {
+            defaultACL {
+                browse
+                submit
+                comment
+                edit
+                triage
+            }
+            acls(cursor: $cursor) {
+                results {
+                    id
+                    created
+                    entity { canonicalName }
+                    browse
+                    submit
+                    comment
+                    edit
+                    triage
+                }
+                cursor
+            }
+        }
+    }
+    """
+
+    private static let trackerLabelsQuery = """
+    query trackerLabels($rid: ID!, $cursor: Cursor) {
+        tracker(rid: $rid) {
+            labels(cursor: $cursor) {
+                results {
+                    id
+                    name
+                    backgroundColor
+                    foregroundColor
+                }
+                cursor
+            }
+        }
+    }
+    """
+
+    private static let updateTrackerMutation = """
+    mutation updateTracker($id: Int!, $input: TrackerInput!) {
+        updateTracker(id: $id, input: $input) {
+            id
+            rid
+            name
+            description
+            visibility
+            updated
+            owner { canonicalName }
+        }
+    }
+    """
+
+    private static let deleteTrackerMutation = """
+    mutation deleteTracker($id: Int!) {
+        deleteTracker(id: $id) {
+            id
+        }
+    }
+    """
+
+    private static let updateUserACLMutation = """
+    mutation updateUserACL($trackerId: Int!, $userId: Int!, $input: ACLInput!) {
+        updateUserACL(trackerId: $trackerId, userId: $userId, input: $input) {
+            id
+            created
+            entity { canonicalName }
+            browse
+            submit
+            comment
+            edit
+            triage
+        }
+    }
+    """
+
+    private static let updateTrackerACLMutation = """
+    mutation updateTrackerACL($trackerId: Int!, $input: ACLInput!) {
+        updateTrackerACL(trackerId: $trackerId, input: $input) {
+            browse
+            submit
+            comment
+            edit
+            triage
+        }
+    }
+    """
+
+    private static let deleteACLMutation = """
+    mutation deleteACL($id: Int!) {
+        deleteACL(id: $id) {
+            id
+            created
+            entity { canonicalName }
+            browse
+            submit
+            comment
+            edit
+            triage
+        }
+    }
+    """
+
+    private static let createLabelMutation = """
+    mutation createLabel($trackerId: Int!, $name: String!, $foregroundColor: String!, $backgroundColor: String!) {
+        createLabel(trackerId: $trackerId, name: $name, foregroundColor: $foregroundColor, backgroundColor: $backgroundColor) {
+            id
+            name
+            backgroundColor
+            foregroundColor
+        }
+    }
+    """
+
+    private static let updateLabelMutation = """
+    mutation updateLabel($id: Int!, $input: UpdateLabelInput!) {
+        updateLabel(id: $id, input: $input) {
+            id
+            name
+            backgroundColor
+            foregroundColor
+        }
+    }
+    """
+
+    private static let deleteLabelMutation = """
+    mutation deleteLabel($id: Int!) {
+        deleteLabel(id: $id) {
+            id
+            name
+            backgroundColor
+            foregroundColor
+        }
+    }
+    """
+
+    private static let userLookupQuery = """
+    query userLookup($username: String!) {
+        user(username: $username) {
+            id
+        }
+    }
+    """
+
+    func refreshTracker() async {
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.trackerQuery,
+                variables: ["rid": tracker.rid],
+                responseType: TrackerQueryResponse.self
+            )
+            if let tracker = result.tracker {
+                self.tracker = tracker
+            }
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    func updateTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
+        guard !isSavingTracker else { return nil }
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else {
+            error = "Enter a tracker name."
+            return nil
+        }
+
+        isSavingTracker = true
+        error = nil
+        defer { isSavingTracker = false }
+
+        var input: [String: any Sendable] = [
+            "name": trimmedName,
+            "visibility": visibility.rawValue
+        ]
+        let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+        input["description"] = trimmedDescription.isEmpty ? "" : trimmedDescription
+
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.updateTrackerMutation,
+                variables: [
+                    "id": tracker.id,
+                    "input": input
+                ],
+                responseType: UpdateTrackerResponse.self
+            )
+            tracker = result.updateTracker
+            return result.updateTracker
+        } catch {
+            self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
+            return nil
+        }
+    }
+
+    func deleteTracker() async -> Bool {
+        guard !isDeletingTracker else { return false }
+        isDeletingTracker = true
+        error = nil
+        defer { isDeletingTracker = false }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.deleteTrackerMutation,
+                variables: ["id": tracker.id],
+                responseType: DeleteTrackerResponse.self
+            )
+            didDeleteTracker = true
+            return true
+        } catch {
+            self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
+            return false
+        }
+    }
+
+    func loadACLs() async {
+        guard !isLoadingACLs else { return }
+        isLoadingACLs = true
+        error = nil
+        defer { isLoadingACLs = false }
+
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.trackerACLsQuery,
+                variables: ["rid": tracker.rid],
+                responseType: TrackerACLQueryResponse.self
+            )
+            defaultACL = result.tracker?.defaultACL ?? defaultACL
+            acls = result.tracker?.acls.results ?? []
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    func updateDefaultACL(_ permissions: TrackerACLPermissions) async -> Bool {
+        guard !isSavingACL else { return false }
+        isSavingACL = true
+        error = nil
+        defer { isSavingACL = false }
+
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.updateTrackerACLMutation,
+                variables: [
+                    "trackerId": tracker.id,
+                    "input": permissions.graphQLInput
+                ],
+                responseType: UpdateTrackerACLResponse.self
+            )
+            defaultACL = result.updateTrackerACL
+            await loadACLs()
+            return true
+        } catch {
+            self.error = error.userFacingMessage
+            return false
+        }
+    }
+
+    func addOrUpdateACL(username: String, permissions: TrackerACLPermissions) async -> Bool {
+        guard !isSavingACL else { return false }
+        let normalizedUsername = Self.normalizedUsername(username)
+        guard !normalizedUsername.isEmpty else {
+            error = "Enter a SourceHut username."
+            return false
+        }
+
+        isSavingACL = true
+        error = nil
+        defer { isSavingACL = false }
+
+        do {
+            let userResult = try await client.execute(
+                service: .todo,
+                query: Self.userLookupQuery,
+                variables: ["username": normalizedUsername],
+                responseType: TrackerUserLookupResponse.self
+            )
+            guard let userId = userResult.user?.id else {
+                error = "That user couldn’t be found."
+                return false
+            }
+
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.updateUserACLMutation,
+                variables: [
+                    "trackerId": tracker.id,
+                    "userId": userId,
+                    "input": permissions.graphQLInput
+                ],
+                responseType: UpdateUserACLResponse.self
+            )
+            if let index = acls.firstIndex(where: { $0.id == result.updateUserACL.id }) {
+                acls[index] = result.updateUserACL
+            } else {
+                acls.append(result.updateUserACL)
+                acls.sort { $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending }
+            }
+            await loadACLs()
+            return true
+        } catch {
+            self.error = error.userFacingMessage
+            return false
+        }
+    }
+
+    func deleteACL(_ entry: TrackerACL) async {
+        guard !isDeletingACL else { return }
+        isDeletingACL = true
+        error = nil
+        defer { isDeletingACL = false }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.deleteACLMutation,
+                variables: ["id": entry.id],
+                responseType: DeleteTrackerACLResponse.self
+            )
+            acls.removeAll { $0.id == entry.id }
+            await loadACLs()
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    func loadLabels() async {
+        guard !isLoadingLabels else { return }
+        isLoadingLabels = true
+        error = nil
+        defer { isLoadingLabels = false }
+
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.trackerLabelsQuery,
+                variables: ["rid": tracker.rid],
+                responseType: TrackerLabelQueryResponse.self
+            )
+            labels = result.tracker?.labels.results ?? []
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    func createLabel(name: String, foregroundColor: String, backgroundColor: String) async -> Bool {
+        guard !isSavingLabel else { return false }
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else {
+            error = "Enter a label name."
+            return false
+        }
+        guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
+            error = "Label colors must use #RRGGBB format."
+            return false
+        }
+
+        isSavingLabel = true
+        error = nil
+        defer { isSavingLabel = false }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.createLabelMutation,
+                variables: [
+                    "trackerId": tracker.id,
+                    "name": trimmedName,
+                    "foregroundColor": foregroundColor,
+                    "backgroundColor": backgroundColor
+                ],
+                responseType: CreateTrackerLabelResponse.self
+            )
+            await loadLabels()
+            return true
+        } catch {
+            self.error = error.userFacingMessage
+            return false
+        }
+    }
+
+    func updateLabel(
+        _ label: TicketLabel,
+        name: String,
+        foregroundColor: String,
+        backgroundColor: String
+    ) async -> Bool {
+        guard !isSavingLabel else { return false }
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else {
+            error = "Enter a label name."
+            return false
+        }
+        guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
+            error = "Label colors must use #RRGGBB format."
+            return false
+        }
+
+        isSavingLabel = true
+        error = nil
+        defer { isSavingLabel = false }
+
+        var input: [String: any Sendable] = [:]
+        if trimmedName != label.name {
+            input["name"] = trimmedName
+        }
+        if foregroundColor.caseInsensitiveCompare(label.foregroundColor) != .orderedSame {
+            input["foregroundColor"] = foregroundColor
+        }
+        if backgroundColor.caseInsensitiveCompare(label.backgroundColor) != .orderedSame {
+            input["backgroundColor"] = backgroundColor
+        }
+
+        guard !input.isEmpty else { return true }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.updateLabelMutation,
+                variables: [
+                    "id": label.id,
+                    "input": input
+                ],
+                responseType: UpdateTrackerLabelResponse.self
+            )
+            await loadLabels()
+            return true
+        } catch {
+            self.error = error.userFacingMessage
+            return false
+        }
+    }
+
+    func deleteLabel(_ label: TicketLabel) async {
+        guard !isDeletingLabel else { return }
+        isDeletingLabel = true
+        error = nil
+        defer { isDeletingLabel = false }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.deleteLabelMutation,
+                variables: ["id": label.id],
+                responseType: DeleteTrackerLabelResponse.self
+            )
+            labels.removeAll { $0.id == label.id }
+            await loadLabels()
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    static func normalizedUsername(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return "" }
+        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+    }
+
+    static func isValidHexColor(_ value: String) -> Bool {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmed.count == 7, trimmed.first == "#" else { return false }
+        return trimmed.dropFirst().allSatisfy { $0.isHexDigit }
+    }
+}
+
+private extension TrackerACLPermissions {
+    var graphQLInput: [String: any Sendable] {
+        [
+            "browse": browse,
+            "submit": submit,
+            "comment": comment,
+            "edit": edit,
+            "triage": triage
+        ]
+    }
+}
+
+struct TrackerEditorSheet: View {
+    let title: String
+    let confirmationTitle: String
+    let isSaving: Bool
+    let error: String?
+    let initialName: String
+    let initialDescription: String
+    let initialVisibility: Visibility
+    let onSave: (String, String, Visibility) async -> Bool
+
+    @Environment(\.dismiss) private var dismiss
+    @State private var name: String
+    @State private var description: String
+    @State private var visibility: Visibility
+
+    init(
+        title: String,
+        confirmationTitle: String,
+        isSaving: Bool,
+        error: String?,
+        initialName: String,
+        initialDescription: String,
+        initialVisibility: Visibility,
+        onSave: @escaping (String, String, Visibility) async -> Bool
+    ) {
+        self.title = title
+        self.confirmationTitle = confirmationTitle
+        self.isSaving = isSaving
+        self.error = error
+        self.initialName = initialName
+        self.initialDescription = initialDescription
+        self.initialVisibility = initialVisibility
+        self.onSave = onSave
+        _name = State(initialValue: initialName)
+        _description = State(initialValue: initialDescription)
+        _visibility = State(initialValue: initialVisibility)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section("Tracker Details") {
+                    TextField("Tracker name", text: $name)
+                        .textInputAutocapitalization(.never)
+                        .autocorrectionDisabled()
+                    TextField("Short description (optional)", text: $description, axis: .vertical)
+                        .lineLimit(2...4)
+                    Picker("Visibility", selection: $visibility) {
+                        Text("Public").tag(Visibility.public)
+                        Text("Unlisted").tag(Visibility.unlisted)
+                        Text("Private").tag(Visibility.private)
+                    }
+                }
+
+                if let error, !error.isEmpty {
+                    Section {
+                        Text(error)
+                            .foregroundStyle(.red)
+                    }
+                }
+            }
+            .navigationTitle(title)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button {
+                        Task {
+                            let didSave = await onSave(name, description, visibility)
+                            if didSave {
+                                dismiss()
+                            }
+                        }
+                    } label: {
+                        if isSaving {
+                            ProgressView()
+                                .controlSize(.small)
+                        } else {
+                            Text(confirmationTitle)
+                        }
+                    }
+                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
+                }
+            }
+        }
+    }
+}
+
+struct TrackerACLManagementSheet: View {
+    let viewModel: TrackerManagementViewModel
+
+    @Bindable private var bindableViewModel: TrackerManagementViewModel
+    @State private var editingACL: TrackerACL?
+    @State private var editingDefaultACL = false
+    @State private var pendingDeletion: TrackerACL?
+    @State private var showCreateACL = false
+
+    init(viewModel: TrackerManagementViewModel) {
+        self.viewModel = viewModel
+        self._bindableViewModel = Bindable(viewModel)
+    }
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section("Default Access") {
+                    TrackerPermissionSummary(permissions: viewModel.defaultACL.permissions)
+                    Button("Update Default ACL") {
+                        editingDefaultACL = true
+                    }
+                    .disabled(viewModel.isSavingACL)
+                }
+
+                Section {
+                    if viewModel.isLoadingACLs {
+                        HStack {
+                            Spacer()
+                            ProgressView()
+                            Spacer()
+                        }
+                    } else if viewModel.acls.isEmpty {
+                        Text("No tracker-specific ACLs yet.")
+                            .foregroundStyle(.secondary)
+                    } else {
+                        ForEach(viewModel.acls) { entry in
+                            VStack(alignment: .leading, spacing: 6) {
+                                Text(entry.entity.canonicalName)
+                                    .font(.subheadline.weight(.medium))
+                                TrackerPermissionSummary(permissions: entry.permissions)
+                            }
+                            .swipeActions(edge: .leading, allowsFullSwipe: false) {
+                                Button {
+                                    editingACL = entry
+                                } label: {
+                                    Label("Edit", systemImage: "pencil")
+                                }
+                                .tint(.blue)
+                            }
+                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                                Button(role: .destructive) {
+                                    pendingDeletion = entry
+                                } label: {
+                                    Label("Delete", systemImage: "trash")
+                                }
+                            }
+                        }
+                    }
+                } header: {
+                    Text("User ACLs")
+                } footer: {
+                    Text("Each ACL must include all five permission flags.")
+                }
+            }
+            .navigationTitle("ACLs")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        showCreateACL = true
+                    } label: {
+                        Image(systemName: "plus")
+                    }
+                    .disabled(viewModel.isSavingACL)
+                }
+            }
+            .task {
+                await viewModel.loadACLs()
+            }
+            .srhtErrorBanner(error: $bindableViewModel.error)
+            .sheet(isPresented: $showCreateACL) {
+                TrackerACLEditorSheet(
+                    title: "Add ACL",
+                    submitTitle: "Save",
+                    isSaving: viewModel.isSavingACL,
+                    error: viewModel.error,
+                    initialUsername: "",
+                    initialPermissions: viewModel.defaultACL.permissions
+                ) { username, permissions in
+                    await viewModel.addOrUpdateACL(username: username, permissions: permissions)
+                }
+            }
+            .sheet(item: $editingACL) { entry in
+                TrackerACLEditorSheet(
+                    title: "Update ACL",
+                    submitTitle: "Save",
+                    isSaving: viewModel.isSavingACL,
+                    error: viewModel.error,
+                    initialUsername: entry.entity.canonicalName,
+                    initialPermissions: entry.permissions
+                ) { username, permissions in
+                    await viewModel.addOrUpdateACL(username: username, permissions: permissions)
+                }
+            }
+            .sheet(isPresented: $editingDefaultACL) {
+                TrackerDefaultACLEditorSheet(
+                    isSaving: viewModel.isSavingACL,
+                    error: viewModel.error,
+                    initialPermissions: viewModel.defaultACL.permissions
+                ) { permissions in
+                    await viewModel.updateDefaultACL(permissions)
+                }
+            }
+            .alert("Remove Access?", isPresented: Binding(
+                get: { pendingDeletion != nil },
+                set: { isPresented in
+                    if !isPresented {
+                        pendingDeletion = nil
+                    }
+                }
+            )) {
+                Button("Cancel", role: .cancel) {}
+                Button("Delete", role: .destructive) {
+                    guard let pendingDeletion else { return }
+                    Task {
+                        await viewModel.deleteACL(pendingDeletion)
+                        self.pendingDeletion = nil
+                    }
+                }
+            } message: {
+                if let pendingDeletion {
+                    Text("\(pendingDeletion.entity.canonicalName) will fall back to the tracker default ACL.")
+                }
+            }
+        }
+    }
+}
+
+private struct TrackerPermissionSummary: View {
+    let permissions: TrackerACLPermissions
+
+    var body: some View {
+        Text(summary)
+            .font(.caption)
+            .foregroundStyle(.secondary)
+    }
+
+    private var summary: String {
+        let items = [
+            permissions.browse ? "browse" : nil,
+            permissions.submit ? "submit" : nil,
+            permissions.comment ? "comment" : nil,
+            permissions.edit ? "edit" : nil,
+            permissions.triage ? "triage" : nil
+        ].compactMap { $0 }
+        return items.isEmpty ? "No permissions" : items.joined(separator: ", ")
+    }
+}
+
+private struct TrackerACLEditorSheet: View {
+    let title: String
+    let submitTitle: String
+    let isSaving: Bool
+    let error: String?
+    let initialUsername: String
+    let initialPermissions: TrackerACLPermissions
+    let onSave: (String, TrackerACLPermissions) async -> Bool
+
+    @Environment(\.dismiss) private var dismiss
+    @State private var username: String
+    @State private var browse: Bool
+    @State private var submit: Bool
+    @State private var comment: Bool
+    @State private var edit: Bool
+    @State private var triage: Bool
+
+    init(
+        title: String,
+        submitTitle: String,
+        isSaving: Bool,
+        error: String?,
+        initialUsername: String,
+        initialPermissions: TrackerACLPermissions,
+        onSave: @escaping (String, TrackerACLPermissions) async -> Bool
+    ) {
+        self.title = title
+        self.submitTitle = submitTitle
+        self.isSaving = isSaving
+        self.error = error
+        self.initialUsername = initialUsername
+        self.initialPermissions = initialPermissions
+        self.onSave = onSave
+        _username = State(initialValue: initialUsername)
+        _browse = State(initialValue: initialPermissions.browse)
+        _submit = State(initialValue: initialPermissions.submit)
+        _comment = State(initialValue: initialPermissions.comment)
+        _edit = State(initialValue: initialPermissions.edit)
+        _triage = State(initialValue: initialPermissions.triage)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section("User") {
+                    TextField("Username or ~username", text: $username)
+                        .autocorrectionDisabled()
+                        .textInputAutocapitalization(.never)
+                }
+
+                permissionSection
+
+                if let error, !error.isEmpty {
+                    Section {
+                        Text(error)
+                            .foregroundStyle(.red)
+                    }
+                }
+            }
+            .navigationTitle(title)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button {
+                        Task {
+                            let didSave = await onSave(username, permissions)
+                            if didSave {
+                                dismiss()
+                            }
+                        }
+                    } label: {
+                        if isSaving {
+                            ProgressView()
+                                .controlSize(.small)
+                        } else {
+                            Text(submitTitle)
+                        }
+                    }
+                    .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
+                }
+            }
+        }
+    }
+
+    private var permissionSection: some View {
+        Section("Permissions") {
+            Toggle("Browse", isOn: $browse)
+            Toggle("Submit", isOn: $submit)
+            Toggle("Comment", isOn: $comment)
+            Toggle("Edit", isOn: $edit)
+            Toggle("Triage", isOn: $triage)
+        }
+    }
+
+    private var permissions: TrackerACLPermissions {
+        TrackerACLPermissions(
+            browse: browse,
+            submit: submit,
+            comment: comment,
+            edit: edit,
+            triage: triage
+        )
+    }
+}
+
+private struct TrackerDefaultACLEditorSheet: View {
+    let isSaving: Bool
+    let error: String?
+    let initialPermissions: TrackerACLPermissions
+    let onSave: (TrackerACLPermissions) async -> Bool
+
+    @Environment(\.dismiss) private var dismiss
+    @State private var browse: Bool
+    @State private var submit: Bool
+    @State private var comment: Bool
+    @State private var edit: Bool
+    @State private var triage: Bool
+
+    init(
+        isSaving: Bool,
+        error: String?,
+        initialPermissions: TrackerACLPermissions,
+        onSave: @escaping (TrackerACLPermissions) async -> Bool
+    ) {
+        self.isSaving = isSaving
+        self.error = error
+        self.initialPermissions = initialPermissions
+        self.onSave = onSave
+        _browse = State(initialValue: initialPermissions.browse)
+        _submit = State(initialValue: initialPermissions.submit)
+        _comment = State(initialValue: initialPermissions.comment)
+        _edit = State(initialValue: initialPermissions.edit)
+        _triage = State(initialValue: initialPermissions.triage)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section("Permissions") {
+                    Toggle("Browse", isOn: $browse)
+                    Toggle("Submit", isOn: $submit)
+                    Toggle("Comment", isOn: $comment)
+                    Toggle("Edit", isOn: $edit)
+                    Toggle("Triage", isOn: $triage)
+                }
+
+                if let error, !error.isEmpty {
+                    Section {
+                        Text(error)
+                            .foregroundStyle(.red)
+                    }
+                }
+            }
+            .navigationTitle("Default ACL")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button {
+                        Task {
+                            let didSave = await onSave(
+                                TrackerACLPermissions(
+                                    browse: browse,
+                                    submit: submit,
+                                    comment: comment,
+                                    edit: edit,
+                                    triage: triage
+                                )
+                            )
+                            if didSave {
+                                dismiss()
+                            }
+                        }
+                    } label: {
+                        if isSaving {
+                            ProgressView()
+                                .controlSize(.small)
+                        } else {
+                            Text("Save")
+                        }
+                    }
+                    .disabled(isSaving)
+                }
+            }
+        }
+    }
+}
+
+struct TrackerLabelManagementSheet: View {
+    let viewModel: TrackerManagementViewModel
+
+    @Bindable private var bindableViewModel: TrackerManagementViewModel
+    @State private var showCreateLabel = false
+    @State private var editingLabel: TicketLabel?
+    @State private var pendingDeletion: TicketLabel?
+
+    init(viewModel: TrackerManagementViewModel) {
+        self.viewModel = viewModel
+        self._bindableViewModel = Bindable(viewModel)
+    }
+
+    var body: some View {
+        NavigationStack {
+            List {
+                if viewModel.isLoadingLabels {
+                    HStack {
+                        Spacer()
+                        ProgressView()
+                        Spacer()
+                    }
+                } else if viewModel.labels.isEmpty {
+                    ContentUnavailableView(
+                        "No Labels",
+                        systemImage: "tag",
+                        description: Text("Create labels for triage and organization.")
+                    )
+                } else {
+                    ForEach(viewModel.labels) { label in
+                        HStack {
+                            LabelPill(label: label)
+                            Spacer()
+                        }
+                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                            Button {
+                                editingLabel = label
+                            } label: {
+                                Label("Edit", systemImage: "pencil")
+                            }
+                            .tint(.blue)
+
+                            Button(role: .destructive) {
+                                pendingDeletion = label
+                            } label: {
+                                Label("Delete", systemImage: "trash")
+                            }
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Labels")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        showCreateLabel = true
+                    } label: {
+                        Image(systemName: "plus")
+                    }
+                    .disabled(viewModel.isSavingLabel)
+                }
+            }
+            .task {
+                await viewModel.loadLabels()
+            }
+            .srhtErrorBanner(error: $bindableViewModel.error)
+            .sheet(isPresented: $showCreateLabel) {
+                TrackerLabelEditorSheet(
+                    title: "New Label",
+                    submitTitle: "Create",
+                    isSaving: viewModel.isSavingLabel,
+                    error: viewModel.error,
+                    initialLabel: nil
+                ) { name, foreground, background in
+                    await viewModel.createLabel(
+                        name: name,
+                        foregroundColor: foreground,
+                        backgroundColor: background
+                    )
+                }
+            }
+            .sheet(item: $editingLabel) { label in
+                TrackerLabelEditorSheet(
+                    title: "Update Label",
+                    submitTitle: "Save",
+                    isSaving: viewModel.isSavingLabel,
+                    error: viewModel.error,
+                    initialLabel: label
+                ) { name, foreground, background in
+                    await viewModel.updateLabel(
+                        label,
+                        name: name,
+                        foregroundColor: foreground,
+                        backgroundColor: background
+                    )
+                }
+            }
+            .alert("Delete Label?", isPresented: Binding(
+                get: { pendingDeletion != nil },
+                set: { isPresented in
+                    if !isPresented {
+                        pendingDeletion = nil
+                    }
+                }
+            )) {
+                Button("Cancel", role: .cancel) {}
+                Button("Delete", role: .destructive) {
+                    guard let pendingDeletion else { return }
+                    Task {
+                        await viewModel.deleteLabel(pendingDeletion)
+                        self.pendingDeletion = nil
+                    }
+                }
+            } message: {
+                if let pendingDeletion {
+                    Text("“\(pendingDeletion.name)” will be removed from this tracker and from any tickets using it.")
+                }
+            }
+        }
+    }
+}
+
+private struct TrackerLabelEditorSheet: View {
+    let title: String
+    let submitTitle: String
+    let isSaving: Bool
+    let error: String?
+    let initialLabel: TicketLabel?
+    let onSave: (String, String, String) async -> Bool
+
+    @Environment(\.dismiss) private var dismiss
+    @State private var name: String
+    @State private var foregroundColor: Color
+    @State private var backgroundColor: Color
+
+    init(
+        title: String,
+        submitTitle: String,
+        isSaving: Bool,
+        error: String?,
+        initialLabel: TicketLabel?,
+        onSave: @escaping (String, String, String) async -> Bool
+    ) {
+        self.title = title
+        self.submitTitle = submitTitle
+        self.isSaving = isSaving
+        self.error = error
+        self.initialLabel = initialLabel
+        self.onSave = onSave
+        _name = State(initialValue: initialLabel?.name ?? "")
+        _foregroundColor = State(initialValue: Color(hex: initialLabel?.foregroundColor ?? "#ffffff") ?? .white)
+        _backgroundColor = State(initialValue: Color(hex: initialLabel?.backgroundColor ?? "#000000") ?? .black)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section("Details") {
+                    TextField("Label name", text: $name)
+                    ColorPicker("Foreground", selection: $foregroundColor, supportsOpacity: false)
+                    ColorPicker("Background", selection: $backgroundColor, supportsOpacity: false)
+                }
+
+                Section("Preview") {
+                    LabelPill(
+                        label: TicketLabel(
+                            id: initialLabel?.id ?? -1,
+                            name: name.isEmpty ? "Preview" : name,
+                            backgroundColor: backgroundColor.hexString,
+                            foregroundColor: foregroundColor.hexString
+                        )
+                    )
+                }
+
+                if let error, !error.isEmpty {
+                    Section {
+                        Text(error)
+                            .foregroundStyle(.red)
+                    }
+                }
+            }
+            .navigationTitle(title)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button {
+                        Task {
+                            let didSave = await onSave(
+                                name,
+                                foregroundColor.hexString,
+                                backgroundColor.hexString
+                            )
+                            if didSave {
+                                dismiss()
+                            }
+                        }
+                    } label: {
+                        if isSaving {
+                            ProgressView()
+                                .controlSize(.small)
+                        } else {
+                            Text(submitTitle)
+                        }
+                    }
+                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
+                }
+            }
+        }
+    }
+}
diff --git a/HutchTests/TrackerManagementViewModelTests.swift b/HutchTests/TrackerManagementViewModelTests.swift
new file mode 100644
index 0000000..78edfc9
--- /dev/null
+++ b/HutchTests/TrackerManagementViewModelTests.swift
@@ -0,0 +1,36 @@
+import Testing
+@testable import Hutch
+
+struct TrackerManagementViewModelTests {
+
+    @Test
+    func normalizedUsernameStripsLeadingTildeAndWhitespace() {
+        #expect(TrackerManagementViewModel.normalizedUsername("  ~alice  ") == "alice")
+        #expect(TrackerManagementViewModel.normalizedUsername("bob") == "bob")
+    }
+
+    @Test
+    func hexColorValidationRequiresPoundAndSixHexDigits() {
+        #expect(TrackerManagementViewModel.isValidHexColor("#a1B2c3"))
+        #expect(!TrackerManagementViewModel.isValidHexColor("a1B2c3"))
+        #expect(!TrackerManagementViewModel.isValidHexColor("#12345"))
+        #expect(!TrackerManagementViewModel.isValidHexColor("#12GG45"))
+    }
+
+    @Test
+    func trackerACLPermissionsExposeBooleanFlags() {
+        let permissions = TrackerACLPermissions(
+            browse: true,
+            submit: false,
+            comment: true,
+            edit: false,
+            triage: true
+        )
+
+        #expect(permissions.browse)
+        #expect(!permissions.submit)
+        #expect(permissions.comment)
+        #expect(!permissions.edit)
+        #expect(permissions.triage)
+    }
+}