summaryrefslogtreecommitdiff
path: root/Rune/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 11:48:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 11:48:40 -0500
commitfcf864a15b70e4ecb5bd789b1db3116221c34394 (patch)
treef15315324b9f95286b6bf7392dd0ccaee210c2de /Rune/Views
parentcf587aa0573ac4f34e1effe70108a9eca82093ac (diff)
downloadrune-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.swift28
-rw-r--r--Rune/Views/Domains/DomainEditView.swift117
-rw-r--r--Rune/Views/Domains/DomainListView.swift71
-rw-r--r--Rune/Views/Domains/ForwardAddView.swift111
-rw-r--r--Rune/Views/Domains/ForwardListView.swift171
-rw-r--r--Rune/Views/Domains/RecordAddView.swift37
-rw-r--r--Rune/Views/Domains/RecordEditView.swift51
-rw-r--r--Rune/Views/Domains/RecordListView.swift55
-rw-r--r--Rune/Views/Shared/FeedbackViews.swift38
-rw-r--r--Rune/Views/Tokens/TokenAddView.swift98
-rw-r--r--Rune/Views/Tokens/TokenListView.swift92
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)
}