summaryrefslogtreecommitdiff
path: root/Rune/Views/Domains
diff options
context:
space:
mode:
Diffstat (limited to 'Rune/Views/Domains')
-rw-r--r--Rune/Views/Domains/DomainDetailView.swift114
-rw-r--r--Rune/Views/Domains/DomainEditView.swift99
-rw-r--r--Rune/Views/Domains/DomainListView.swift86
-rw-r--r--Rune/Views/Domains/RecordAddView.swift144
-rw-r--r--Rune/Views/Domains/RecordEditView.swift105
-rw-r--r--Rune/Views/Domains/RecordListView.swift81
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)
+ }
+}