diff options
| author | Christian Cleberg <[email protected]> | 2026-03-24 21:48:23 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-24 21:48:23 -0500 |
| commit | cf587aa0573ac4f34e1effe70108a9eca82093ac (patch) | |
| tree | 833e6f4961522e35b08af01b5f012983f8e34678 /Rune/Views/Domains | |
| download | rune-cf587aa0573ac4f34e1effe70108a9eca82093ac.tar.gz rune-cf587aa0573ac4f34e1effe70108a9eca82093ac.tar.bz2 rune-cf587aa0573ac4f34e1effe70108a9eca82093ac.zip | |
v1.0v1.0.0
Diffstat (limited to 'Rune/Views/Domains')
| -rw-r--r-- | Rune/Views/Domains/DomainDetailView.swift | 114 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainEditView.swift | 99 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainListView.swift | 86 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordAddView.swift | 144 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordEditView.swift | 105 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordListView.swift | 81 |
6 files changed, 629 insertions, 0 deletions
diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift new file mode 100644 index 0000000..dbb90a1 --- /dev/null +++ b/Rune/Views/Domains/DomainDetailView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct DomainDetailView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + var body: some View { + Group { + if viewModel.isLoadingDetail && viewModel.selectedDomain?.name != domainName { + ProgressView() + } else if let domain = currentDomain { + List { + Section("Status") { + DetailRow(label: "Name", value: domain.name) + DetailRow(label: "Status", value: textValue(domain.status)) + DetailRow(label: "Expiry", value: domain.expiry?.formattedExpiry() ?? "Not available") + DetailRow(label: "Autorenew", value: boolText(domain.autorenew)) + } + + Section("Settings") { + DetailRow(label: "Mail Forwarding", value: boolText(domain.mailforwarding)) + DetailRow(label: "DNSSEC", value: boolText(domain.dnssec)) + DetailRow(label: "Registrar Lock", value: boolText(domain.lock)) + DetailRow(label: "Nameservers", value: nameserverText(domain.nameservers)) + } + + Section("DNS") { + NavigationLink("Records") { + RecordListView(domainName: domain.name, viewModel: viewModel, client: client) + } + } + } + .listStyle(.insetGrouped) + .toolbar { + NavigationLink("Edit") { + DomainEditView(domain: domain, viewModel: viewModel, client: client) + } + } + } else { + ContentUnavailableView("Domain Unavailable", systemImage: "globe", description: Text("The domain details could not be loaded.")) + } + } + .navigationTitle(domainName) + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadDomainDetail(named: domainName, client: client) + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var currentDomain: Domain? { + if viewModel.selectedDomain?.name == domainName { + return viewModel.selectedDomain + } + + return viewModel.domains.first(where: { $0.name == domainName }) + } + + private func boolText(_ value: Bool?) -> String { + guard let value else { return "Not available" } + return value ? "On" : "Off" + } + + private func nameserverText(_ nameservers: [String]?) -> String { + guard let nameservers else { + return "Not available" + } + + guard !nameservers.isEmpty else { + return "Default" + } + + return nameservers.joined(separator: ", ") + } + + private func textValue(_ value: String?) -> String { + guard let value, !value.isEmpty else { + return "Not available" + } + + return value + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} + +private struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } +} diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift new file mode 100644 index 0000000..3f197e3 --- /dev/null +++ b/Rune/Views/Domains/DomainEditView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct DomainEditView: View { + let domain: Domain + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var autorenew: Bool + @State private var mailforwarding: Bool + @State private var dnssec: Bool + @State private var lock: Bool + @State private var nameserversText: String + @State private var localErrorMessage: String? + + init(domain: Domain, viewModel: DomainViewModel, client: NjallaClient) { + self.domain = domain + self.viewModel = viewModel + self.client = client + _autorenew = State(initialValue: domain.autorenew ?? false) + _mailforwarding = State(initialValue: domain.mailforwarding ?? false) + _dnssec = State(initialValue: domain.dnssec ?? false) + _lock = State(initialValue: domain.lock ?? false) + _nameserversText = State(initialValue: (domain.nameservers ?? []).joined(separator: "\n")) + } + + 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) + } + + Section { + TextEditor(text: $nameserversText) + .frame(minHeight: 120) + } header: { + Text("Nameservers") + } footer: { + Text("Enter one nameserver per line. Leave blank to use Njalla defaults.") + } + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving) + } + } + .navigationTitle("Edit Domain") + .navigationBarTitleDisplayMode(.inline) + .alert("API Error", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + } + + 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 } + ) + + do { + try await viewModel.updateDomain(named: domain.name, request: request, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } +} diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift new file mode 100644 index 0000000..d728bd7 --- /dev/null +++ b/Rune/Views/Domains/DomainListView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct DomainListView: View { + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient? + + var body: some View { + NavigationStack { + Group { + if let client { + content(client: client) + } else { + ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load domains.")) + } + } + .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) + } + } + .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 + } + } + ) + } +} + +private struct DomainRow: View { + let domain: Domain + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(domain.name) + .font(.headline) + + HStack { + if let status = domain.status { + Text(status) + } + + if let expiry = domain.expiry { + Text("Expiry: \(expiry.formattedExpiry())") + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + + if let autorenew = domain.autorenew { + Text(autorenew ? "Autorenew On" : "Autorenew Off") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} diff --git a/Rune/Views/Domains/RecordAddView.swift b/Rune/Views/Domains/RecordAddView.swift new file mode 100644 index 0000000..8dfa81d --- /dev/null +++ b/Rune/Views/Domains/RecordAddView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct RecordAddView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var draft = DNSRecordDraft() + @State private var localErrorMessage: String? + var body: some View { + Form { + DNSRecordFormSections(draft: $draft) + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving || !draft.canSubmit) + } + } + .navigationTitle("Add Record") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + } + } + .onChange(of: draft.type) { oldValue, newValue in + guard oldValue != newValue else { return } + draft.resetTypeSpecificFields() + } + .alert("API Error", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + } + + private func save() async { + do { + try await viewModel.addRecord(for: domainName, draft: draft, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } +} + +struct DNSRecordFormSections: View { + @Binding var draft: DNSRecordDraft + + var body: some View { + Section("Record") { + Picker("Type", selection: $draft.type) { + ForEach(DNSRecordType.allCases) { type in + Text(type.rawValue).tag(type) + } + } + + TextField("Name", text: $draft.name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + if draft.type.usesContent { + Section("Content") { + TextField("Content", text: $draft.content, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + if draft.type.usesTTL { + Section("TTL") { + TextField("TTL", text: $draft.ttl) + .keyboardType(.numberPad) + } + } + + if draft.type.usesPriority { + Section("Priority") { + TextField("Priority", text: $draft.prio) + .keyboardType(.numberPad) + } + } + + if draft.type.usesWeight { + Section("Weight") { + TextField("Weight", text: $draft.weight) + .keyboardType(.numberPad) + } + } + + if draft.type.usesPort { + Section("Port") { + TextField("Port", text: $draft.port) + .keyboardType(.numberPad) + } + } + + if draft.type.usesTarget { + Section("Target") { + TextField("Target", text: $draft.target) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + if draft.type.usesSSHFields { + Section { + TextField("SSH Algorithm", text: $draft.sshAlgorithm) + .keyboardType(.numberPad) + TextField("SSH Type", text: $draft.sshType) + .keyboardType(.numberPad) + } header: { + Text("SSHFP") + } footer: { + Text("Algorithm values: 1-5. Type values: 1-2.") + } + } + } +} diff --git a/Rune/Views/Domains/RecordEditView.swift b/Rune/Views/Domains/RecordEditView.swift new file mode 100644 index 0000000..e0c0fde --- /dev/null +++ b/Rune/Views/Domains/RecordEditView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct RecordEditView: View { + let domainName: String + let record: DNSRecord + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @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 + self.record = record + self.viewModel = viewModel + self.client = client + _draft = State(initialValue: DNSRecordDraft(record: record)) + } + + var body: some View { + Form { + DNSRecordFormSections(draft: $draft) + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving || !draft.canSubmit) + } + + Section { + Button("Delete Record", role: .destructive) { + showingDeleteConfirmation = true + } + .foregroundStyle(.red) + } + } + .navigationTitle(record.name) + .navigationBarTitleDisplayMode(.inline) + .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 + ) { + Button("Delete Record", role: .destructive) { + Task { + await deleteRecord() + } + } + } + .alert("API Error", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + } + + private func save() async { + 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 + } + } + + private func deleteRecord() async { + do { + try await viewModel.removeRecord(record, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } +} diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift new file mode 100644 index 0000000..67a4140 --- /dev/null +++ b/Rune/Views/Domains/RecordListView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct RecordListView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @State private var showingAddRecord = false + + var body: some View { + Group { + if viewModel.isLoadingRecords && viewModel.records.isEmpty { + ProgressView() + } else if viewModel.records.isEmpty { + ContentUnavailableView("No Records", systemImage: "list.bullet", description: Text("No DNS records for this domain.")) + } else { + List(viewModel.records) { record in + NavigationLink { + RecordEditView(domainName: domainName, record: record, viewModel: viewModel, client: client) + } label: { + RecordRow(record: record) + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("DNS Records") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button { + showingAddRecord = true + } label: { + Label("Add Record", systemImage: "plus") + } + } + .sheet(isPresented: $showingAddRecord) { + NavigationStack { + RecordAddView(domainName: domainName, viewModel: viewModel, client: client) + } + } + .task { + await viewModel.loadRecords(for: domainName, client: client) + } + .refreshable { + await viewModel.loadRecords(for: domainName, client: client) + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} + +private struct RecordRow: View { + let record: DNSRecord + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("\(record.type) \(record.name)") + .font(.headline) + if let detail = [record.content, record.target].compactMap({ $0 }).first, !detail.isEmpty { + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} |
