diff options
| author | Christian Cleberg <[email protected]> | 2026-04-11 11:48:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-11 11:48:40 -0500 |
| commit | fcf864a15b70e4ecb5bd789b1db3116221c34394 (patch) | |
| tree | f15315324b9f95286b6bf7392dd0ccaee210c2de /Rune/Views | |
| parent | cf587aa0573ac4f34e1effe70108a9eca82093ac (diff) | |
| download | rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.gz rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.bz2 rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.zip | |
add FUNDING.yml
Diffstat (limited to 'Rune/Views')
| -rw-r--r-- | Rune/Views/Domains/DomainDetailView.swift | 28 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainEditView.swift | 117 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainListView.swift | 71 | ||||
| -rw-r--r-- | Rune/Views/Domains/ForwardAddView.swift | 111 | ||||
| -rw-r--r-- | Rune/Views/Domains/ForwardListView.swift | 171 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordAddView.swift | 37 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordEditView.swift | 51 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordListView.swift | 55 | ||||
| -rw-r--r-- | Rune/Views/Shared/FeedbackViews.swift | 38 | ||||
| -rw-r--r-- | Rune/Views/Tokens/TokenAddView.swift | 98 | ||||
| -rw-r--r-- | Rune/Views/Tokens/TokenListView.swift | 92 |
11 files changed, 749 insertions, 120 deletions
diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift index dbb90a1..b4ee00d 100644 --- a/Rune/Views/Domains/DomainDetailView.swift +++ b/Rune/Views/Domains/DomainDetailView.swift @@ -25,6 +25,12 @@ struct DomainDetailView: View { DetailRow(label: "Nameservers", value: nameserverText(domain.nameservers)) } + Section("Email") { + NavigationLink("Forwards") { + ForwardListView(domainName: domain.name, viewModel: viewModel, client: client) + } + } + Section("DNS") { NavigationLink("Records") { RecordListView(domainName: domain.name, viewModel: viewModel, client: client) @@ -49,7 +55,12 @@ struct DomainDetailView: View { .alert("API Error", isPresented: errorBinding) { Button("OK", role: .cancel) {} } message: { - Text(viewModel.errorMessage ?? "") + Text(viewModel.detailErrorMessage ?? "") + } + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") } } @@ -88,10 +99,21 @@ struct DomainDetailView: View { private var errorBinding: Binding<Bool> { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.detailErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.detailErrorMessage = nil + } + } + ) + } + + private var mutationErrorBinding: Binding<Bool> { + Binding( + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - viewModel.errorMessage = nil + viewModel.dismissMutationError() } } ) diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift index 3f197e3..0b3b1f8 100644 --- a/Rune/Views/Domains/DomainEditView.swift +++ b/Rune/Views/Domains/DomainEditView.swift @@ -8,10 +8,15 @@ struct DomainEditView: View { @Environment(\.dismiss) private var dismiss @State private var autorenew: Bool + @State private var autorenewDirty = false @State private var mailforwarding: Bool + @State private var mailforwardingDirty = false @State private var dnssec: Bool + @State private var dnssecDirty = false @State private var lock: Bool + @State private var lockDirty = false @State private var nameserversText: String + @State private var nameserversDirty = false @State private var localErrorMessage: String? init(domain: Domain, viewModel: DomainViewModel, client: NjallaClient) { @@ -28,14 +33,14 @@ struct DomainEditView: View { var body: some View { Form { Section("Settings") { - Toggle("Autorenew", isOn: $autorenew) - Toggle("Mail Forwarding", isOn: $mailforwarding) - Toggle("DNSSEC", isOn: $dnssec) - Toggle("Registrar Lock", isOn: $lock) + Toggle("Autorenew", isOn: dirtyBinding(for: $autorenew, dirty: $autorenewDirty, original: originalAutorenew)) + Toggle("Mail Forwarding", isOn: dirtyBinding(for: $mailforwarding, dirty: $mailforwardingDirty, original: originalMailForwarding)) + Toggle("DNSSEC", isOn: dirtyBinding(for: $dnssec, dirty: $dnssecDirty, original: originalDNSSEC)) + Toggle("Registrar Lock", isOn: dirtyBinding(for: $lock, dirty: $lockDirty, original: originalLock)) } Section { - TextEditor(text: $nameserversText) + TextEditor(text: nameserversBinding) .frame(minHeight: 120) } header: { Text("Nameservers") @@ -49,12 +54,27 @@ struct DomainEditView: View { await save() } } - .disabled(viewModel.isSaving) + .disabled(viewModel.isSaving || !request.hasChanges) } } .navigationTitle("Edit Domain") .navigationBarTitleDisplayMode(.inline) - .alert("API Error", isPresented: localErrorBinding) { + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + .disabled(viewModel.isSaving) + } + } + .interactiveDismissDisabled(viewModel.isSaving) + .alert("Request Failed", isPresented: localErrorBinding) { Button("OK", role: .cancel) {} } message: { Text(localErrorMessage ?? "") @@ -62,16 +82,16 @@ struct DomainEditView: View { } private func save() async { - let request = DomainUpdateRequest( - autorenew: autorenew, - mailforwarding: mailforwarding, - dnssec: dnssec, - lock: lock, - nameservers: nameserversText - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - ) + guard !viewModel.isSaving else { + return + } + + let request = request + + guard request.hasChanges else { + dismiss() + return + } do { try await viewModel.updateDomain(named: domain.name, request: request, client: client) @@ -82,7 +102,7 @@ struct DomainEditView: View { if (error as? URLError)?.code == .cancelled { return } - localErrorMessage = error.localizedDescription + localErrorMessage = error.userFacingMessage } } @@ -96,4 +116,65 @@ struct DomainEditView: View { } ) } + + private var originalAutorenew: Bool { + domain.autorenew ?? false + } + + private var originalMailForwarding: Bool { + domain.mailforwarding ?? false + } + + private var originalDNSSEC: Bool { + domain.dnssec ?? false + } + + private var originalLock: Bool { + domain.lock ?? false + } + + private var originalNameservers: [String] { + normalizedNameservers(from: (domain.nameservers ?? []).joined(separator: "\n")) + } + + private var request: DomainUpdateRequest { + DomainUpdateRequest( + autorenew: autorenewDirty ? autorenew : nil, + mailforwarding: mailforwardingDirty ? mailforwarding : nil, + dnssec: dnssecDirty ? dnssec : nil, + lock: lockDirty ? lock : nil, + nameservers: nameserversDirty ? normalizedNameservers(from: nameserversText) : nil + ) + } + + private var nameserversBinding: Binding<String> { + Binding( + get: { nameserversText }, + set: { newValue in + nameserversText = newValue + nameserversDirty = normalizedNameservers(from: newValue) != originalNameservers + } + ) + } + + private func dirtyBinding( + for value: Binding<Bool>, + dirty: Binding<Bool>, + original: Bool + ) -> Binding<Bool> { + Binding( + get: { value.wrappedValue }, + set: { newValue in + value.wrappedValue = newValue + dirty.wrappedValue = newValue != original + } + ) + } + + private func normalizedNameservers(from text: String) -> [String] { + text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } } diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift index d728bd7..68f2ede 100644 --- a/Rune/Views/Domains/DomainListView.swift +++ b/Rune/Views/Domains/DomainListView.swift @@ -15,43 +15,58 @@ struct DomainListView: View { } .navigationTitle("Domains") } - .alert("API Error", isPresented: errorBinding) { - Button("OK", role: .cancel) {} - } message: { - Text(viewModel.errorMessage ?? "") - } } @ViewBuilder private func content(client: NjallaClient) -> some View { - if viewModel.isLoadingDomains && viewModel.domains.isEmpty { - ProgressView() - } else if viewModel.domains.isEmpty { - ContentUnavailableView("No Domains", systemImage: "globe", description: Text("No domains found on this account.")) - } else { - List(viewModel.domains) { domain in - NavigationLink { - DomainDetailView(domainName: domain.name, viewModel: viewModel, client: client) - } label: { - DomainRow(domain: domain) + List { + if let errorMessage = viewModel.domainsErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Domains") { + Task { + await viewModel.loadDomains(client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.loadDomains(client: client) - } - } - } - private var errorBinding: Binding<Bool> { - Binding( - get: { viewModel.errorMessage != nil }, - set: { newValue in - if !newValue { - viewModel.errorMessage = nil + if (!viewModel.hasLoadedDomains || viewModel.isLoadingDomains) && viewModel.domains.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Domains") + Spacer() + } + } + } else if viewModel.domains.isEmpty { + Section { + ContentUnavailableView( + "No Domains", + systemImage: "globe", + description: Text("No domains found. Pull to refresh after domains are added to this account.") + ) + } + } else { + ForEach(viewModel.domains) { domain in + NavigationLink { + DomainDetailView(domainName: domain.name, viewModel: viewModel, client: client) + } label: { + DomainRow(domain: domain) + } } } - ) + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadDomains(client: client) + } + .overlay(alignment: .top) { + if viewModel.isLoadingDomains && !viewModel.domains.isEmpty { + ProgressView() + .padding(.top, 8) + } + } } } diff --git a/Rune/Views/Domains/ForwardAddView.swift b/Rune/Views/Domains/ForwardAddView.swift new file mode 100644 index 0000000..222ab86 --- /dev/null +++ b/Rune/Views/Domains/ForwardAddView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct ForwardAddView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var from = "" + @State private var to = "" + + var body: some View { + Form { + Section { + TextField("From", text: $from) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("To", text: $to) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + } header: { + Text("Forward") + } footer: { + Text("Creates \(trimmedFrom)@\(domainName) -> \(trimmedTo)") + } + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving || !canSubmit) + } + } + .navigationTitle("Add Forward") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + .disabled(viewModel.isSaving) + } + } + .interactiveDismissDisabled(viewModel.isSaving) + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") + } + } + + private var trimmedFrom: String { + from.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedTo: String { + to.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + !trimmedFrom.isEmpty && !trimmedTo.isEmpty + } + + private func save() async { + guard !viewModel.isSaving, canSubmit else { + return + } + + let forward = EmailForward(domain: domainName, from: trimmedFrom, to: trimmedTo) + + debugLog("Creating forward \(forward.from)@\(forward.domain) -> \(forward.to)") + do { + try await viewModel.addForward(forward, client: client) + debugLog("Created forward \(forward.from)@\(forward.domain) -> \(forward.to)") + dismiss() + } catch is CancellationError { + debugLog("Create cancelled for \(forward.from)@\(forward.domain) -> \(forward.to)") + return + } catch { + debugLog("Create failed for \(forward.from)@\(forward.domain) -> \(forward.to): \(error.localizedDescription)") + return + } + } + + private var mutationErrorBinding: Binding<Bool> { + Binding( + get: { viewModel.mutationErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.dismissMutationError() + } + } + ) + } + + private func debugLog(_ message: String) { + #if DEBUG + debugPrint("[ForwardAddView]", message) + #endif + } +} diff --git a/Rune/Views/Domains/ForwardListView.swift b/Rune/Views/Domains/ForwardListView.swift new file mode 100644 index 0000000..4e20ab2 --- /dev/null +++ b/Rune/Views/Domains/ForwardListView.swift @@ -0,0 +1,171 @@ +import SwiftUI + +struct ForwardListView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @State private var showingAddForward = false + @State private var forwardPendingDeletion: EmailForward? + + var body: some View { + List { + if let errorMessage = viewModel.forwardsErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Forwards") { + Task { + await viewModel.loadForwards(for: domainName, client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoadingForwards && viewModel.forwards.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Forwards") + Spacer() + } + } + } else if viewModel.forwards.isEmpty { + Section { + ContentUnavailableView( + "No Forwards", + systemImage: "envelope", + description: Text("No email forwards are configured for this domain.") + ) + } + } else { + ForEach(viewModel.forwards) { forward in + VStack(alignment: .leading, spacing: 4) { + Text("@\(forward.from)") + .font(.headline) + Text(forward.to) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .swipeActions { + Button("Delete", role: .destructive) { + guard !viewModel.isSaving else { return } + forwardPendingDeletion = forward + } + } + .contextMenu { + Button("Delete Forward", role: .destructive) { + forwardPendingDeletion = forward + } + } + .disabled(viewModel.isSaving) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Forwards") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button { + showingAddForward = true + } label: { + Label("Add Forward", systemImage: "plus") + } + .disabled(viewModel.isSaving) + } + .sheet(isPresented: $showingAddForward) { + NavigationStack { + ForwardAddView(domainName: domainName, viewModel: viewModel, client: client) + } + } + .task { + debugLog("Loading forwards for \(domainName)") + await viewModel.loadForwards(for: domainName, client: client) + debugLog("Loaded \(viewModel.forwards.count) forwards for \(domainName)") + } + .refreshable { + debugLog("Refreshing forwards for \(domainName)") + await viewModel.loadForwards(for: domainName, client: client) + debugLog("Refresh complete with \(viewModel.forwards.count) forwards for \(domainName)") + } + .overlay(alignment: .top) { + if viewModel.isLoadingForwards && !viewModel.forwards.isEmpty { + ProgressView() + .padding(.top, 8) + } + } + .alert(deleteAlertTitle, isPresented: deleteBinding) { + Button("Delete Forward", role: .destructive) { + guard let forwardPendingDeletion else { return } + Task { + await delete(forwardPendingDeletion) + } + } + Button("Cancel", role: .cancel) { + forwardPendingDeletion = nil + } + } message: { + Text("Delete the forward from \(forwardPendingDeletion?.from ?? "") to \(forwardPendingDeletion?.to ?? "")?") + } + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") + } + } + + private func delete(_ forward: EmailForward) async { + guard !viewModel.isSaving else { + return + } + + debugLog("Deleting forward \(forward.from)@\(forward.domain) -> \(forward.to)") + do { + try await viewModel.removeForward(forward, client: client) + debugLog("Deleted forward \(forward.from)@\(forward.domain) -> \(forward.to)") + forwardPendingDeletion = nil + } catch is CancellationError { + debugLog("Delete cancelled for \(forward.from)@\(forward.domain) -> \(forward.to)") + return + } catch { + debugLog("Delete failed for \(forward.from)@\(forward.domain) -> \(forward.to): \(error.localizedDescription)") + return + } + } + + private var deleteAlertTitle: String { + guard let forwardPendingDeletion else { + return "" + } + + return "Delete forward \(forwardPendingDeletion.from)@\(domainName)?" + } + + private var deleteBinding: Binding<Bool> { + Binding( + get: { forwardPendingDeletion != nil }, + set: { newValue in + if !newValue { + forwardPendingDeletion = nil + } + } + ) + } + + private var mutationErrorBinding: Binding<Bool> { + Binding( + get: { viewModel.mutationErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.dismissMutationError() + } + } + ) + } + + private func debugLog(_ message: String) { + #if DEBUG + debugPrint("[ForwardListView]", message) + #endif + } +} diff --git a/Rune/Views/Domains/RecordAddView.swift b/Rune/Views/Domains/RecordAddView.swift index 8dfa81d..2cc5dde 100644 --- a/Rune/Views/Domains/RecordAddView.swift +++ b/Rune/Views/Domains/RecordAddView.swift @@ -8,7 +8,6 @@ struct RecordAddView: View { @Environment(\.dismiss) private var dismiss @State private var draft = DNSRecordDraft() - @State private var localErrorMessage: String? var body: some View { Form { DNSRecordFormSections(draft: $draft) @@ -24,44 +23,54 @@ struct RecordAddView: View { } .navigationTitle("Add Record") .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", role: .cancel) { dismiss() } + .disabled(viewModel.isSaving) } } + .interactiveDismissDisabled(viewModel.isSaving) .onChange(of: draft.type) { oldValue, newValue in guard oldValue != newValue else { return } draft.resetTypeSpecificFields() } - .alert("API Error", isPresented: localErrorBinding) { + .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { - Text(localErrorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } private func save() async { + guard !viewModel.isSaving else { + return + } + do { try await viewModel.addRecord(for: domainName, draft: draft, client: client) + draft = DNSRecordDraft() dismiss() } catch is CancellationError { return } catch { - if (error as? URLError)?.code == .cancelled { - return - } - localErrorMessage = error.localizedDescription + return } } - private var localErrorBinding: Binding<Bool> { + private var mutationErrorBinding: Binding<Bool> { Binding( - get: { localErrorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - localErrorMessage = nil + viewModel.dismissMutationError() } } ) @@ -94,8 +103,12 @@ struct DNSRecordFormSections: View { if draft.type.usesTTL { Section("TTL") { - TextField("TTL", text: $draft.ttl) - .keyboardType(.numberPad) + Picker("TTL", selection: $draft.ttlSeconds) { + ForEach(draft.ttlOptions) { option in + Text(option.isCustom ? "Custom (\(option.label))" : option.label) + .tag(option.seconds) + } + } } } diff --git a/Rune/Views/Domains/RecordEditView.swift b/Rune/Views/Domains/RecordEditView.swift index e0c0fde..fceaaba 100644 --- a/Rune/Views/Domains/RecordEditView.swift +++ b/Rune/Views/Domains/RecordEditView.swift @@ -10,7 +10,6 @@ struct RecordEditView: View { @State private var draft: DNSRecordDraft @State private var showingDeleteConfirmation = false - @State private var localErrorMessage: String? init(domainName: String, record: DNSRecord, viewModel: DomainViewModel, client: NjallaClient) { self.domainName = domainName @@ -35,71 +34,85 @@ struct RecordEditView: View { Section { Button("Delete Record", role: .destructive) { + guard !viewModel.isSaving else { return } showingDeleteConfirmation = true } .foregroundStyle(.red) + .disabled(viewModel.isSaving) } } .navigationTitle(record.name) .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } + .interactiveDismissDisabled(viewModel.isSaving) .onChange(of: draft.type) { oldValue, newValue in guard oldValue != newValue else { return } draft.resetTypeSpecificFields() } - .confirmationDialog( - "Delete \(record.type) record \(record.name)?", - isPresented: $showingDeleteConfirmation, - titleVisibility: .visible - ) { + .alert(deleteAlertTitle, isPresented: $showingDeleteConfirmation) { Button("Delete Record", role: .destructive) { Task { await deleteRecord() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This action cannot be undone.") } - .alert("API Error", isPresented: localErrorBinding) { + .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { - Text(localErrorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } private func save() async { + guard !viewModel.isSaving else { + return + } + do { try await viewModel.editRecord(for: domainName, recordID: record.id, draft: draft, client: client) dismiss() } catch is CancellationError { return } catch { - if (error as? URLError)?.code == .cancelled { - return - } - localErrorMessage = error.localizedDescription + return } } private func deleteRecord() async { + guard !viewModel.isSaving else { + return + } + do { try await viewModel.removeRecord(record, client: client) dismiss() } catch is CancellationError { return } catch { - if (error as? URLError)?.code == .cancelled { - return - } - localErrorMessage = error.localizedDescription + return } } - private var localErrorBinding: Binding<Bool> { + private var mutationErrorBinding: Binding<Bool> { Binding( - get: { localErrorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - localErrorMessage = nil + viewModel.dismissMutationError() } } ) } + + private var deleteAlertTitle: String { + "Delete \(record.type) record \(record.name)?" + } } diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift index 67a4140..6c40ab0 100644 --- a/Rune/Views/Domains/RecordListView.swift +++ b/Rune/Views/Domains/RecordListView.swift @@ -8,22 +8,45 @@ struct RecordListView: View { @State private var showingAddRecord = false var body: some View { - Group { + List { + if let errorMessage = viewModel.recordsErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Records") { + Task { + await viewModel.loadRecords(for: domainName, client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + if viewModel.isLoadingRecords && viewModel.records.isEmpty { - ProgressView() + Section { + HStack { + Spacer() + ProgressView("Loading Records") + Spacer() + } + } } else if viewModel.records.isEmpty { - ContentUnavailableView("No Records", systemImage: "list.bullet", description: Text("No DNS records for this domain.")) + Section { + ContentUnavailableView( + "No Records", + systemImage: "list.bullet", + description: Text("No DNS records for this domain yet. Add a record to get started.") + ) + } } else { - List(viewModel.records) { record in + ForEach(viewModel.records) { record in NavigationLink { RecordEditView(domainName: domainName, record: record, viewModel: viewModel, client: client) } label: { RecordRow(record: record) } } - .listStyle(.insetGrouped) } } + .listStyle(.insetGrouped) .navigationTitle("DNS Records") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -41,22 +64,34 @@ struct RecordListView: View { .task { await viewModel.loadRecords(for: domainName, client: client) } + .onAppear { + viewModel.startAutoRefreshRecords(for: domainName, client: client) + } + .onDisappear { + viewModel.stopAutoRefreshRecords() + } .refreshable { await viewModel.loadRecords(for: domainName, client: client) } - .alert("API Error", isPresented: errorBinding) { + .overlay(alignment: .top) { + if viewModel.isLoadingRecords && !viewModel.records.isEmpty { + ProgressView() + .padding(.top, 8) + } + } + .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { - Text(viewModel.errorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } - private var errorBinding: Binding<Bool> { + private var mutationErrorBinding: Binding<Bool> { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - viewModel.errorMessage = nil + viewModel.dismissMutationError() } } ) diff --git a/Rune/Views/Shared/FeedbackViews.swift b/Rune/Views/Shared/FeedbackViews.swift new file mode 100644 index 0000000..9267ce2 --- /dev/null +++ b/Rune/Views/Shared/FeedbackViews.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct InlineErrorView: View { + let message: String + let retryTitle: String + let retryAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(message) + .font(.subheadline) + .foregroundStyle(.red) + + Button(retryTitle, action: retryAction) + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +struct FeedbackBanner: View { + let message: String + let tint: Color + + var body: some View { + Text(message) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal) + .padding(.top, 8) + } +} diff --git a/Rune/Views/Tokens/TokenAddView.swift b/Rune/Views/Tokens/TokenAddView.swift index 6b71783..184398f 100644 --- a/Rune/Views/Tokens/TokenAddView.swift +++ b/Rune/Views/Tokens/TokenAddView.swift @@ -9,8 +9,12 @@ struct TokenAddView: View { @State private var comment = "" @State private var fromText = "" @State private var allowedMethodsText = "" + @State private var allowedDomainsText = "" + @State private var unrestrictedConfirmed = false @State private var ipValidationMessage: String? @State private var methodValidationMessage: String? + @State private var domainValidationMessage: String? + @State private var restrictionValidationMessage: String? var body: some View { Form { @@ -47,6 +51,36 @@ struct TokenAddView: View { } Section { + TextEditor(text: $allowedDomainsText) + .frame(minHeight: 100) + if let domainValidationMessage { + Text(domainValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("Allowed Domains") + } footer: { + Text("Optional. Enter one domain per line to limit the token to specific domains.") + } + + Section("Restrictions") { + Text("At least one restriction is recommended. Tokens without restrictions can access any allowed API method from any origin.") + .font(.subheadline) + .foregroundStyle(.secondary) + + if lines(fromText).isEmpty && lines(allowedMethodsText).isEmpty && lines(allowedDomainsText).isEmpty { + Toggle("I understand this token will be unrestricted", isOn: $unrestrictedConfirmed) + } + + if let restrictionValidationMessage { + Text(restrictionValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + + Section { Button("Save") { Task { await save() @@ -57,27 +91,48 @@ struct TokenAddView: View { } .navigationTitle("Add Token") .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", role: .cancel) { dismiss() } + .disabled(viewModel.isSaving) } } + .interactiveDismissDisabled(viewModel.isSaving) .onChange(of: fromText) { _, _ in ipValidationMessage = nil + restrictionValidationMessage = nil + unrestrictedConfirmed = false } .onChange(of: allowedMethodsText) { _, _ in methodValidationMessage = nil + restrictionValidationMessage = nil + unrestrictedConfirmed = false } - .alert("API Error", isPresented: errorBinding) { + .onChange(of: allowedDomainsText) { _, _ in + domainValidationMessage = nil + restrictionValidationMessage = nil + unrestrictedConfirmed = false + } + .alert("Request Failed", isPresented: errorBinding) { Button("OK", role: .cancel) {} } message: { - Text(viewModel.errorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } private func save() async { + guard !viewModel.isSaving else { + return + } + guard validateInput() else { return } @@ -85,10 +140,12 @@ struct TokenAddView: View { let request = TokenCreateRequest( comment: comment, from: lines(fromText), - allowedMethods: lines(allowedMethodsText) + allowedMethods: lines(allowedMethodsText), + allowedDomains: lines(allowedDomainsText) ) if await viewModel.addToken(request: request, client: client) { + resetForm() dismiss() } } @@ -103,6 +160,7 @@ struct TokenAddView: View { private func validateInput() -> Bool { let ipEntries = lines(fromText) let methodEntries = lines(allowedMethodsText) + let domainEntries = lines(allowedDomainsText) ipValidationMessage = ipEntries.allSatisfy(isValidIPOrCIDR(_:)) ? nil @@ -112,7 +170,19 @@ struct TokenAddView: View { ? nil : "One or more method names appear invalid. Use format: list-domains" - return ipValidationMessage == nil && methodValidationMessage == nil + domainValidationMessage = domainEntries.allSatisfy(isValidDomainName(_:)) + ? nil + : "One or more domain entries appear invalid." + + let hasRestrictions = !ipEntries.isEmpty || !methodEntries.isEmpty || !domainEntries.isEmpty + restrictionValidationMessage = hasRestrictions || unrestrictedConfirmed + ? nil + : "Add at least one restriction or confirm unrestricted token creation." + + return ipValidationMessage == nil && + methodValidationMessage == nil && + domainValidationMessage == nil && + restrictionValidationMessage == nil } private func isValidMethodName(_ value: String) -> Bool { @@ -124,12 +194,28 @@ struct TokenAddView: View { return value.range(of: pattern, options: .regularExpression) != nil } + private func isValidDomainName(_ value: String) -> Bool { + value.range(of: #"^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$"#, options: .regularExpression) != nil + } + + private func resetForm() { + comment = "" + fromText = "" + allowedMethodsText = "" + allowedDomainsText = "" + unrestrictedConfirmed = false + ipValidationMessage = nil + methodValidationMessage = nil + domainValidationMessage = nil + restrictionValidationMessage = nil + } + private var errorBinding: Binding<Bool> { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - viewModel.errorMessage = nil + viewModel.dismissMutationError() } } ) diff --git a/Rune/Views/Tokens/TokenListView.swift b/Rune/Views/Tokens/TokenListView.swift index 51bae49..bbe50d9 100644 --- a/Rune/Views/Tokens/TokenListView.swift +++ b/Rune/Views/Tokens/TokenListView.swift @@ -28,21 +28,18 @@ struct TokenListView: View { } } } - .sheet(isPresented: $showingAddToken) { + .fullScreenCover(isPresented: $showingAddToken) { if let client { NavigationStack { TokenAddView(viewModel: viewModel, client: client) } } } - .confirmationDialog( - deletionTitle, - isPresented: deleteBinding, - titleVisibility: .visible - ) { + .alert(deletionTitle, isPresented: deleteBinding) { Button("Delete Token", role: .destructive) { guard let tokenPendingDeletion, let client else { return } Task { + guard !viewModel.isSaving else { return } let removed = await viewModel.removeToken(tokenPendingDeletion, client: client) if removed { onTokenRemoved(tokenPendingDeletion.key) @@ -50,32 +47,73 @@ struct TokenListView: View { self.tokenPendingDeletion = nil } } + + Button("Cancel", role: .cancel) { + tokenPendingDeletion = nil + } + } message: { + Text("This action cannot be undone.") } - .alert("API Error", isPresented: errorBinding) { + .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { - Text(viewModel.errorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } @ViewBuilder private func content(client: NjallaClient) -> some View { - if viewModel.isLoading && viewModel.tokens.isEmpty { - ProgressView() - } else if viewModel.tokens.isEmpty { - ContentUnavailableView("No Tokens", systemImage: "key.horizontal", description: Text("No API tokens were found on this account.")) - } else { - List(viewModel.tokens) { token in - Button { - tokenPendingDeletion = token - } label: { + List { + if let errorMessage = viewModel.listErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Tokens") { + Task { + await viewModel.loadTokens(client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoading && viewModel.tokens.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Tokens") + Spacer() + } + } + } else if viewModel.tokens.isEmpty { + Section { + ContentUnavailableView( + "No Tokens", + systemImage: "key.horizontal", + description: Text("No API tokens found. Create a restricted token for specific access.") + ) + } + } else { + ForEach(viewModel.tokens) { token in TokenRow(token: token, label: viewModel.tokenLabel(for: token)) + .contentShape(Rectangle()) + .contextMenu { + Button("Delete Token", role: .destructive) { + tokenPendingDeletion = token + } + } + .disabled(viewModel.isSaving) + .opacity(viewModel.isSaving ? 0.6 : 1) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } - .buttonStyle(.plain) } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.loadTokens(client: client) + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadTokens(client: client) + } + .overlay(alignment: .top) { + if viewModel.isLoading && !viewModel.tokens.isEmpty { + ProgressView() + .padding(.top, 8) } } } @@ -99,12 +137,12 @@ struct TokenListView: View { ) } - private var errorBinding: Binding<Bool> { + private var mutationErrorBinding: Binding<Bool> { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - viewModel.errorMessage = nil + viewModel.dismissMutationError() } } ) @@ -129,6 +167,12 @@ private struct TokenRow: View { .font(.subheadline) .foregroundStyle(.secondary) } + + if let domains = token.allowedDomains, !domains.isEmpty { + Text("Domains: \(domains.joined(separator: ", "))") + .font(.subheadline) + .foregroundStyle(.secondary) + } } .padding(.vertical, 4) } |
