krz/hutch

an ios client for sourcehut

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

main: Hutch/Views/Repositories/RepositoryDeployKeysView.swift · raw

  1import SwiftUI
  2
  3@Observable
  4@MainActor
  5final class RepositoryDeployKeysViewModel {
  6    private(set) var keys: [RepositoryDeployKey] = []
  7    private(set) var isLoading = false
  8    private(set) var isSaving = false
  9    private(set) var deletingRID: String?
 10    var loadError: String?
 11    var error: String?
 12    var saveError: String?
 13
 14    let repositoryRid: String
 15    private let service: RepositoryDeployKeyService
 16
 17    init(repositoryRid: String, service: RepositoryDeployKeyService) {
 18        self.repositoryRid = repositoryRid
 19        self.service = service
 20    }
 21
 22    func load() async {
 23        guard !isLoading else { return }
 24        isLoading = true
 25        loadError = nil
 26        defer { isLoading = false }
 27        do {
 28            keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
 29        } catch {
 30            if keys.isEmpty {
 31                loadError = error.userFacingMessage
 32            } else {
 33                self.error = error.userFacingMessage
 34            }
 35        }
 36    }
 37
 38    func addKey(publicKey: String, mode: AccessMode) async -> Bool {
 39        let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
 40        guard !trimmed.isEmpty, !isSaving else { return false }
 41        isSaving = true
 42        saveError = nil
 43        defer { isSaving = false }
 44        do {
 45            try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
 46            // The create response omits the key's fields, so reload the list.
 47            keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
 48            return true
 49        } catch {
 50            saveError = error.userFacingMessage
 51            return false
 52        }
 53    }
 54
 55    func deleteKey(_ key: RepositoryDeployKey) async {
 56        guard deletingRID == nil else { return }
 57        deletingRID = key.rid
 58        error = nil
 59        defer { deletingRID = nil }
 60        do {
 61            try await service.deleteDeployKey(rid: key.rid)
 62            keys.removeAll { $0.rid == key.rid }
 63        } catch {
 64            self.error = error.userFacingMessage
 65        }
 66    }
 67}
 68
 69struct RepositoryDeployKeysView: View {
 70    let repository: RepositorySummary
 71    let client: SRHTClient
 72    var showsDoneButton = false
 73
 74    @Environment(\.dismiss) private var dismiss
 75    @State private var viewModel: RepositoryDeployKeysViewModel?
 76    @State private var showAddSheet = false
 77    @State private var pendingDeletion: RepositoryDeployKey?
 78
 79    var body: some View {
 80        Group {
 81            if let viewModel {
 82                content(viewModel)
 83            } else {
 84                SRHTLoadingStateView(message: "Loading deploy keys…")
 85            }
 86        }
 87        .navigationTitle("Deploy Keys")
 88        .navigationBarTitleDisplayMode(.inline)
 89        .toolbar {
 90            if showsDoneButton {
 91                ToolbarItem(placement: .cancellationAction) {
 92                    Button("Done") { dismiss() }
 93                }
 94            }
 95            if viewModel != nil {
 96                ToolbarItem(placement: .topBarTrailing) {
 97                    Button {
 98                        showAddSheet = true
 99                    } label: {
100                        Image(systemName: "plus")
101                    }
102                    .accessibilityLabel("Add deploy key")
103                }
104            }
105        }
106        .task {
107            if viewModel == nil {
108                let vm = RepositoryDeployKeysViewModel(
109                    repositoryRid: repository.rid,
110                    service: RepositoryDeployKeyService(client: client)
111                )
112                viewModel = vm
113                await vm.load()
114            }
115        }
116    }
117
118    @ViewBuilder
119    private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
120        Group {
121            if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
122                SRHTLoadingStateView(message: "Loading deploy keys…")
123            } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
124                SRHTErrorStateView(
125                    title: "Couldn't Load Deploy Keys",
126                    message: loadError,
127                    retryAction: { await viewModel.load() }
128                )
129            } else {
130                List {
131                    if viewModel.keys.isEmpty {
132                        Section {
133                            ContentUnavailableView(
134                                "No Deploy Keys",
135                                systemImage: "key",
136                                description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
137                            )
138                            .themedRow()
139                        }
140                    } else {
141                        Section {
142                            ForEach(viewModel.keys) { key in
143                                DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
144                                    .themedRow()
145                                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
146                                        Button(role: .destructive) {
147                                            pendingDeletion = key
148                                        } label: {
149                                            Label("Delete", systemImage: "trash")
150                                        }
151                                    }
152                            }
153                        } footer: {
154                            Text("Deploy keys are SSH keys scoped to this repository only.")
155                        }
156                    }
157                }
158                .themedList()
159                .refreshable { await viewModel.load() }
160            }
161        }
162        .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
163        .confirmationDialog(
164            "Delete this deploy key?",
165            isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
166            titleVisibility: .visible
167        ) {
168            Button("Cancel", role: .cancel) { pendingDeletion = nil }
169            Button("Delete", role: .destructive) {
170                if let key = pendingDeletion {
171                    pendingDeletion = nil
172                    Task { await viewModel.deleteKey(key) }
173                }
174            }
175        } message: {
176            Text("This revokes the key's access to \(repository.name). This cannot be undone.")
177        }
178        .sheet(isPresented: $showAddSheet) {
179            AddDeployKeyView(viewModel: viewModel)
180        }
181    }
182}
183
184private struct DeployKeyRow: View {
185    let key: RepositoryDeployKey
186    let isDeleting: Bool
187
188    var body: some View {
189        HStack(spacing: 12) {
190            VStack(alignment: .leading, spacing: 3) {
191                Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
192                    .font(.body)
193                    .lineLimit(1)
194                Text(key.fingerprintSHA256)
195                    .font(.caption.monospaced())
196                    .foregroundStyle(.secondary)
197                    .lineLimit(1)
198                    .truncationMode(.middle)
199            }
200            Spacer()
201            if isDeleting {
202                ProgressView().controlSize(.small)
203            } else {
204                Text(key.access.displayName)
205                    .font(.caption.weight(.medium))
206                    .foregroundStyle(.secondary)
207            }
208        }
209        .padding(.vertical, 2)
210    }
211}
212
213private struct AddDeployKeyView: View {
214    let viewModel: RepositoryDeployKeysViewModel
215
216    @Environment(\.dismiss) private var dismiss
217    @State private var publicKey = ""
218    @State private var mode: AccessMode = .ro
219
220    var body: some View {
221        NavigationStack {
222            Form {
223                Section("SSH Public Key") {
224                    TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
225                        .lineLimit(3...8)
226                        .textInputAutocapitalization(.never)
227                        .autocorrectionDisabled()
228                        .font(.body.monospaced())
229                        .themedRow()
230                }
231                Section {
232                    Picker("Access", selection: $mode) {
233                        Text("Read Only").tag(AccessMode.ro)
234                        Text("Read/Write").tag(AccessMode.rw)
235                    }
236                    .themedRow()
237                } footer: {
238                    Text("Read/Write lets the key push to this repository.")
239                }
240                if let saveError = viewModel.saveError, !saveError.isEmpty {
241                    Section {
242                        Text(saveError).foregroundStyle(.red).themedRow()
243                    }
244                }
245            }
246            .themedList()
247            .navigationTitle("Add Deploy Key")
248            .navigationBarTitleDisplayMode(.inline)
249            .toolbar {
250                ToolbarItem(placement: .cancellationAction) {
251                    Button("Cancel") { dismiss() }
252                }
253                ToolbarItem(placement: .confirmationAction) {
254                    Button {
255                        Task {
256                            if await viewModel.addKey(publicKey: publicKey, mode: mode) {
257                                dismiss()
258                            }
259                        }
260                    } label: {
261                        if viewModel.isSaving {
262                            ProgressView().controlSize(.small)
263                        } else {
264                            Text("Add")
265                        }
266                    }
267                    .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
268                }
269            }
270        }
271    }
272}