summaryrefslogtreecommitdiff
path: root/Rune
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
parentcf587aa0573ac4f34e1effe70108a9eca82093ac (diff)
downloadrune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.gz
rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.bz2
rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.zip
add FUNDING.yml
Diffstat (limited to 'Rune')
-rw-r--r--Rune/API/Models.swift201
-rw-r--r--Rune/API/NjallaClient.swift63
-rw-r--r--Rune/App/RuneApp.swift25
-rw-r--r--Rune/ViewModels/DomainViewModel.swift248
-rw-r--r--Rune/ViewModels/TokenViewModel.swift55
-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
16 files changed, 1259 insertions, 202 deletions
diff --git a/Rune/API/Models.swift b/Rune/API/Models.swift
index f0209b4..161e8df 100644
--- a/Rune/API/Models.swift
+++ b/Rune/API/Models.swift
@@ -6,7 +6,7 @@ struct WalletBalance: Codable, Equatable {
let balance: Int
}
-struct DomainListResponse: Codable {
+struct DomainListResponse: Decodable {
let domains: [Domain]
}
@@ -18,7 +18,11 @@ struct TokenListResponse: Codable {
let tokens: [APIToken]
}
-struct Domain: Codable, Identifiable, Hashable {
+struct ForwardListResponse: Codable {
+ let forwards: [EmailForward]
+}
+
+struct Domain: Decodable, Identifiable, Hashable {
var id: String { name }
let name: String
@@ -29,6 +33,33 @@ struct Domain: Codable, Identifiable, Hashable {
let dnssec: Bool?
let lock: Bool?
let nameservers: [String]?
+
+ enum CodingKeys: String, CodingKey {
+ case name
+ case status
+ case expiry
+ case autorenew
+ case mailforwarding
+ case dnssec
+ case dnssecEnabled = "dnssec_enabled"
+ case lock
+ case locked
+ case nameservers
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ name = try container.decode(String.self, forKey: .name)
+ status = try container.decodeIfPresent(String.self, forKey: .status)
+ expiry = try container.decodeIfPresent(String.self, forKey: .expiry)
+ autorenew = try container.decodeIfPresent(Bool.self, forKey: .autorenew)
+ mailforwarding = try container.decodeIfPresent(Bool.self, forKey: .mailforwarding)
+ dnssec = try container.decodeIfPresent(Bool.self, forKey: .dnssec) ??
+ container.decodeIfPresent(Bool.self, forKey: .dnssecEnabled)
+ lock = try container.decodeIfPresent(Bool.self, forKey: .lock) ??
+ container.decodeIfPresent(Bool.self, forKey: .locked)
+ nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers)
+ }
}
struct DNSRecord: Codable, Identifiable, Hashable {
@@ -147,6 +178,16 @@ struct APIToken: Codable, Identifiable, Hashable {
}
}
+struct EmailForward: Codable, Hashable, Identifiable {
+ let domain: String
+ let from: String
+ let to: String
+
+ var id: String {
+ "\(domain)|\(from)|\(to)"
+ }
+}
+
enum DNSRecordType: String, CaseIterable, Identifiable, Codable {
case a = "A"
case aaaa = "AAAA"
@@ -207,11 +248,67 @@ enum DNSRecordType: String, CaseIterable, Identifiable, Codable {
}
}
+enum TTLPreset: Int, CaseIterable, Identifiable {
+ case zero = 0
+ case oneMinute = 60
+ case fiveMinutes = 300
+ case fifteenMinutes = 900
+ case oneHour = 3600
+ case threeHours = 10800
+ case sixHours = 21600
+ case oneDay = 86400
+
+ var id: Int { rawValue }
+
+ var label: String {
+ switch self {
+ case .zero:
+ return "0s"
+ case .oneMinute:
+ return "1m"
+ case .fiveMinutes:
+ return "5m"
+ case .fifteenMinutes:
+ return "15m"
+ case .oneHour:
+ return "1h"
+ case .threeHours:
+ return "3h"
+ case .sixHours:
+ return "6h"
+ case .oneDay:
+ return "1d"
+ }
+ }
+
+ static func option(for seconds: Int) -> TTLOption {
+ if let preset = TTLPreset(rawValue: seconds) {
+ return TTLOption(seconds: preset.rawValue, label: preset.label)
+ }
+
+ return TTLOption(seconds: seconds, label: "\(seconds)s", isCustom: true)
+ }
+}
+
+struct TTLOption: Hashable, Identifiable {
+ let seconds: Int
+ let label: String
+ let isCustom: Bool
+
+ init(seconds: Int, label: String, isCustom: Bool = false) {
+ self.seconds = seconds
+ self.label = label
+ self.isCustom = isCustom
+ }
+
+ var id: Int { seconds }
+}
+
struct DNSRecordDraft: Equatable {
var type: DNSRecordType = .a
var name = ""
var content = ""
- var ttl = ""
+ var ttlSeconds = TTLPreset.oneHour.rawValue
var prio = ""
var weight = ""
var port = ""
@@ -225,7 +322,7 @@ struct DNSRecordDraft: Equatable {
type = DNSRecordType(rawValue: record.type) ?? .a
name = record.name
content = record.content ?? ""
- ttl = record.ttl.map(String.init) ?? ""
+ ttlSeconds = record.ttl ?? TTLPreset.oneHour.rawValue
prio = record.prio.map(String.init) ?? ""
weight = record.weight.map(String.init) ?? ""
port = record.port.map(String.init) ?? ""
@@ -236,7 +333,7 @@ struct DNSRecordDraft: Equatable {
mutating func resetTypeSpecificFields() {
content = ""
- ttl = ""
+ ttlSeconds = TTLPreset.oneHour.rawValue
prio = ""
weight = ""
port = ""
@@ -253,6 +350,16 @@ struct DNSRecordDraft: Equatable {
!trimmedName.isEmpty
}
+ var ttlOptions: [TTLOption] {
+ let presetOptions = TTLPreset.allCases.map { TTLOption(seconds: $0.rawValue, label: $0.label) }
+
+ guard type.usesTTL, TTLPreset(rawValue: ttlSeconds) == nil else {
+ return presetOptions
+ }
+
+ return presetOptions + [TTLPreset.option(for: ttlSeconds)]
+ }
+
func params(domain: String, id: String? = nil) -> [String: Any] {
var params: [String: Any] = [
"domain": domain,
@@ -268,8 +375,8 @@ struct DNSRecordDraft: Equatable {
params["content"] = content.trimmingCharacters(in: .whitespacesAndNewlines)
}
- if type.usesTTL, let value = Int(ttl.trimmingCharacters(in: .whitespacesAndNewlines)) {
- params["ttl"] = value
+ if type.usesTTL {
+ params["ttl"] = ttlSeconds
}
if type.usesPriority, let value = Int(prio.trimmingCharacters(in: .whitespacesAndNewlines)) {
@@ -302,20 +409,71 @@ struct DNSRecordDraft: Equatable {
}
struct DomainUpdateRequest {
- var autorenew: Bool
- var mailforwarding: Bool
- var dnssec: Bool
- var lock: Bool
- var nameservers: [String]
+ var autorenew: Bool?
+ var mailforwarding: Bool?
+ var dnssec: Bool?
+ var lock: Bool?
+ var nameservers: [String]?
+
+ var hasChanges: Bool {
+ autorenew != nil ||
+ mailforwarding != nil ||
+ dnssec != nil ||
+ lock != nil ||
+ nameservers != nil
+ }
var params: [String: Any] {
- [
- "autorenew": autorenew,
- "mailforwarding": mailforwarding,
- "dnssec": dnssec,
- "lock": lock,
- "nameservers": nameservers
- ]
+ var params: [String: Any] = [:]
+
+ if let autorenew {
+ params["autorenew"] = autorenew
+ }
+
+ if let mailforwarding {
+ params["mailforwarding"] = mailforwarding
+ }
+
+ if let dnssec {
+ params["dnssec"] = dnssec
+ }
+
+ if let lock {
+ params["lock"] = lock
+ }
+
+ if let nameservers {
+ params["nameservers"] = nameservers
+ }
+
+ return params
+ }
+
+ func isSatisfied(by domain: Domain) -> Bool {
+ if let autorenew, domain.autorenew != autorenew {
+ return false
+ }
+
+ if let mailforwarding, domain.mailforwarding != mailforwarding {
+ return false
+ }
+
+ if let dnssec, domain.dnssec != dnssec {
+ return false
+ }
+
+ if let lock, domain.lock != lock {
+ return false
+ }
+
+ if let nameservers {
+ let normalizedResponse = domain.nameservers ?? []
+ if normalizedResponse != nameservers {
+ return false
+ }
+ }
+
+ return true
}
}
@@ -323,6 +481,7 @@ struct TokenCreateRequest {
var comment: String
var from: [String]
var allowedMethods: [String]
+ var allowedDomains: [String]
var params: [String: Any] {
var params: [String: Any] = [:]
@@ -340,6 +499,10 @@ struct TokenCreateRequest {
params["allowed_methods"] = allowedMethods
}
+ if !allowedDomains.isEmpty {
+ params["allowed_domains"] = allowedDomains
+ }
+
return params
}
}
diff --git a/Rune/API/NjallaClient.swift b/Rune/API/NjallaClient.swift
index 9158e20..8da52cb 100644
--- a/Rune/API/NjallaClient.swift
+++ b/Rune/API/NjallaClient.swift
@@ -25,12 +25,7 @@ struct NjallaClient: Sendable, Equatable {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
- let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
-
- #if DEBUG
- debugPrint("Njalla method:", method)
- debugPrint("Njalla status:", statusCode)
- #endif
+ _ = (response as? HTTPURLResponse)?.statusCode ?? -1
let decoder = JSONDecoder()
let envelope = try decoder.decode(RPCResponse<T>.self, from: data)
@@ -61,6 +56,33 @@ struct NjallaClient: Sendable, Equatable {
return try await call("edit-domain", params: params)
}
+ func listForwards(for domain: String) async throws -> [EmailForward] {
+ let response: ForwardListResponse = try await call("list-forwards", params: ["domain": domain])
+ return response.forwards
+ }
+
+ func addForward(forward: EmailForward) async throws {
+ let _: EmptyResult = try await call(
+ "add-forward",
+ params: [
+ "domain": forward.domain,
+ "from": forward.from,
+ "to": forward.to
+ ]
+ )
+ }
+
+ func removeForward(_ forward: EmailForward) async throws {
+ let _: EmptyResult = try await call(
+ "remove-forward",
+ params: [
+ "domain": forward.domain,
+ "from": forward.from,
+ "to": forward.to
+ ]
+ )
+ }
+
func listRecords(for domain: String) async throws -> [DNSRecord] {
let response: RecordListResponse = try await call("list-records", params: ["domain": domain])
return response.records.map { record in
@@ -76,14 +98,17 @@ struct NjallaClient: Sendable, Equatable {
try await call("edit-record", params: draft.params(domain: domain, id: id))
}
- func removeRecord(_ record: DNSRecord) async throws {
+ func removeRecord(_ record: DNSRecord) async throws -> [DNSRecord] {
let params: [String: Any] = [
"domain": record.domain,
"id": record.id,
"name": record.name,
"type": record.type
]
- let _: RecordListResponse = try await call("remove-record", params: params)
+ let response: RecordListResponse = try await call("remove-record", params: params)
+ return response.records.map { item in
+ item.domain.isEmpty ? item.withDomain(record.domain) : item
+ }
}
func listTokens() async throws -> [APIToken] {
@@ -107,6 +132,7 @@ struct NjallaClient: Sendable, Equatable {
enum NjallaError: LocalizedError {
case api(message: String)
case missingResult
+ case networkFailure
var errorDescription: String? {
switch self {
@@ -114,10 +140,31 @@ enum NjallaError: LocalizedError {
return message
case .missingResult:
return "The API response did not include a result."
+ case .networkFailure:
+ return "Network request failed. Check your connection and try again."
}
}
}
+extension Error {
+ var userFacingMessage: String {
+ if let njallaError = self as? NjallaError {
+ return njallaError.localizedDescription
+ }
+
+ if let urlError = self as? URLError {
+ switch urlError.code {
+ case .cancelled:
+ return urlError.localizedDescription
+ default:
+ return NjallaError.networkFailure.localizedDescription
+ }
+ }
+
+ return localizedDescription
+ }
+}
+
private struct RPCResponse<Result: Decodable>: Decodable {
let result: Result?
let error: RPCError?
diff --git a/Rune/App/RuneApp.swift b/Rune/App/RuneApp.swift
index 8c63dd6..1709532 100644
--- a/Rune/App/RuneApp.swift
+++ b/Rune/App/RuneApp.swift
@@ -50,23 +50,30 @@ private struct RootView: View {
}
.task {
await settingsViewModel.bootstrap()
+ await syncAuthenticatedState()
}
- .task(id: settingsViewModel.isAuthenticated) {
- guard let client = settingsViewModel.client else {
- domainViewModel.reset()
- tokenViewModel.reset()
- selectedTab = 0
- return
+ .onChange(of: settingsViewModel.client) { _, _ in
+ Task {
+ await syncAuthenticatedState()
}
-
- await domainViewModel.loadDomains(client: client)
- await tokenViewModel.loadTokens(client: client)
}
.fullScreenCover(isPresented: onboardingBinding) {
OnboardingView(viewModel: settingsViewModel)
}
}
+ private func syncAuthenticatedState() async {
+ guard let client = settingsViewModel.client else {
+ domainViewModel.reset()
+ tokenViewModel.reset()
+ selectedTab = 0
+ return
+ }
+
+ await domainViewModel.loadDomains(client: client)
+ await tokenViewModel.loadTokens(client: client)
+ }
+
private var onboardingBinding: Binding<Bool> {
Binding(
get: { settingsViewModel.requiresOnboarding },
diff --git a/Rune/ViewModels/DomainViewModel.swift b/Rune/ViewModels/DomainViewModel.swift
index efad5b9..1da1bbe 100644
--- a/Rune/ViewModels/DomainViewModel.swift
+++ b/Rune/ViewModels/DomainViewModel.swift
@@ -6,45 +6,67 @@ final class DomainViewModel: ObservableObject {
@Published private(set) var domains: [Domain] = []
@Published private(set) var selectedDomain: Domain?
@Published private(set) var records: [DNSRecord] = []
+ @Published private(set) var forwards: [EmailForward] = []
@Published private(set) var isLoadingDomains = false
@Published private(set) var isLoadingDetail = false
@Published private(set) var isLoadingRecords = false
+ @Published private(set) var isLoadingForwards = false
@Published private(set) var isSaving = false
- @Published var errorMessage: String?
+ @Published private(set) var hasLoadedDomains = false
+ @Published var domainsErrorMessage: String?
+ @Published var detailErrorMessage: String?
+ @Published var recordsErrorMessage: String?
+ @Published var forwardsErrorMessage: String?
+ @Published var mutationErrorMessage: String?
+
+ private var recordsRefreshTask: Task<Void, Never>?
func reset() {
domains = []
selectedDomain = nil
records = []
+ forwards = []
isLoadingDomains = false
isLoadingDetail = false
isLoadingRecords = false
+ isLoadingForwards = false
isSaving = false
- errorMessage = nil
+ hasLoadedDomains = false
+ domainsErrorMessage = nil
+ detailErrorMessage = nil
+ recordsErrorMessage = nil
+ forwardsErrorMessage = nil
+ mutationErrorMessage = nil
+ stopAutoRefreshRecords()
}
func loadDomains(client: NjallaClient) async {
+ guard !isLoadingDomains else { return }
+
isLoadingDomains = true
defer {
isLoadingDomains = false
+ hasLoadedDomains = true
}
do {
domains = try await client.listDomains().sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
- errorMessage = nil
+ domainsErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ domainsErrorMessage = error.userFacingMessage
}
}
func loadDomainDetail(named name: String, client: NjallaClient) async {
+ guard !isLoadingDetail else { return }
+
isLoadingDetail = true
defer {
isLoadingDetail = false
@@ -56,32 +78,98 @@ final class DomainViewModel: ObservableObject {
if let index = domains.firstIndex(where: { $0.name == name }) {
domains[index] = domain
}
- errorMessage = nil
+ detailErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ detailErrorMessage = error.userFacingMessage
}
}
func updateDomain(named name: String, request: DomainUpdateRequest, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let updated = try await client.editDomain(named: name, request: request)
+ guard request.isSatisfied(by: updated) else {
+ throw NjallaError.api(message: "The API response did not reflect the requested domain changes.")
+ }
+ selectedDomain = updated
+ if let index = domains.firstIndex(where: { $0.name == updated.name }) {
+ domains[index] = updated
+ }
+ detailErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadDomainDetail(named: updated.name, client: client)
+ }
+ } catch {
isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
+ }
+
+ func loadRecords(for domain: String, client: NjallaClient) async {
+ await fetchRecords(for: domain, client: client)
+ }
+
+ func startAutoRefreshRecords(for domain: String, client: NjallaClient) {
+ guard recordsRefreshTask == nil else { return }
+
+ recordsRefreshTask = Task { [weak self] in
+ guard let self else { return }
- let updated = try await client.editDomain(named: name, request: request)
- selectedDomain = updated
- if let index = domains.firstIndex(where: { $0.name == updated.name }) {
- domains[index] = updated
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(5))
+
+ guard !Task.isCancelled else { return }
+ await self.fetchRecords(for: domain, client: client)
+ }
}
- errorMessage = nil
}
- func loadRecords(for domain: String, client: NjallaClient) async {
+ func stopAutoRefreshRecords() {
+ recordsRefreshTask?.cancel()
+ recordsRefreshTask = nil
+ }
+
+ func dismissMutationError() {
+ mutationErrorMessage = nil
+ }
+
+ func loadForwards(for domain: String, client: NjallaClient) async {
+ guard !isLoadingForwards else { return }
+
+ isLoadingForwards = true
+ defer {
+ isLoadingForwards = false
+ }
+
+ do {
+ forwards = try await client.listForwards(for: domain).sorted {
+ ($0.from, $0.to) < ($1.from, $1.to)
+ }
+ forwardsErrorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ forwardsErrorMessage = error.userFacingMessage
+ }
+ }
+
+ private func fetchRecords(for domain: String, client: NjallaClient) async {
+ guard !isLoadingRecords else { return }
+
isLoadingRecords = true
defer {
isLoadingRecords = false
@@ -91,53 +179,151 @@ final class DomainViewModel: ObservableObject {
records = try await client.listRecords(for: domain).sorted {
($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
}
- errorMessage = nil
+ recordsErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ recordsErrorMessage = error.userFacingMessage
}
}
func addRecord(for domain: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let record = try await client.addRecord(for: domain, draft: draft).withDomain(domain)
+ guard record.id.isEmpty == false,
+ record.type == draft.type.rawValue,
+ record.name == draft.trimmedName else {
+ throw NjallaError.api(message: "The API response did not reflect the requested record change.")
+ }
+ records = sortedRecords(records + [record])
+ recordsErrorMessage = nil
isSaving = false
+ Task {
+ await loadRecords(for: domain, client: client)
+ }
+ } catch {
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
-
- _ = try await client.addRecord(for: domain, draft: draft)
- try await reloadRecords(for: domain, client: client)
- errorMessage = nil
}
func editRecord(for domain: String, recordID: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let updatedRecord = try await client.editRecord(for: domain, id: recordID, draft: draft).withDomain(domain)
+ guard updatedRecord.id == recordID,
+ updatedRecord.type == draft.type.rawValue,
+ updatedRecord.name == draft.trimmedName else {
+ throw NjallaError.api(message: "The API response did not reflect the requested record change.")
+ }
+ if let index = records.firstIndex(where: { $0.id == recordID }) {
+ records[index] = updatedRecord
+ } else {
+ records.append(updatedRecord)
+ }
+ records = sortedRecords(records)
+ recordsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadRecords(for: domain, client: client)
+ }
+ } catch {
isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
-
- _ = try await client.editRecord(for: domain, id: recordID, draft: draft)
- try await reloadRecords(for: domain, client: client)
- errorMessage = nil
}
func removeRecord(_ record: DNSRecord, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let updatedRecords = try await client.removeRecord(record)
+ guard updatedRecords.contains(where: { $0.id == record.id }) == false else {
+ throw NjallaError.api(message: "The API response indicates the record was not removed.")
+ }
+ records = sortedRecords(updatedRecords)
+ recordsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadRecords(for: record.domain, client: client)
+ }
+ } catch {
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
+ }
+ }
+
+ func addForward(_ forward: EmailForward, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
+ isSaving = true
+ mutationErrorMessage = nil
+
+ do {
+ try await client.addForward(forward: forward)
+ if forwards.contains(forward) == false {
+ forwards = sortedForwards(forwards + [forward])
+ }
+ forwardsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadForwards(for: forward.domain, client: client)
+ }
+ } catch {
isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
+ }
+
+ func removeForward(_ forward: EmailForward, client: NjallaClient) async throws {
+ guard !isSaving else { return }
- try await client.removeRecord(record)
- try await reloadRecords(for: record.domain, client: client)
- errorMessage = nil
+ isSaving = true
+ mutationErrorMessage = nil
+
+ do {
+ try await client.removeForward(forward)
+ forwards.removeAll { $0 == forward }
+ forwardsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadForwards(for: forward.domain, client: client)
+ }
+ } catch {
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
+ }
}
- private func reloadRecords(for domain: String, client: NjallaClient) async throws {
- records = try await client.listRecords(for: domain).sorted {
+ private func sortedRecords(_ records: [DNSRecord]) -> [DNSRecord] {
+ records.sorted {
($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
}
}
+
+ private func sortedForwards(_ forwards: [EmailForward]) -> [EmailForward] {
+ forwards.sorted {
+ ($0.from, $0.to) < ($1.from, $1.to)
+ }
+ }
}
diff --git a/Rune/ViewModels/TokenViewModel.swift b/Rune/ViewModels/TokenViewModel.swift
index 40495b4..eab3185 100644
--- a/Rune/ViewModels/TokenViewModel.swift
+++ b/Rune/ViewModels/TokenViewModel.swift
@@ -6,16 +6,20 @@ final class TokenViewModel: ObservableObject {
@Published private(set) var tokens: [APIToken] = []
@Published private(set) var isLoading = false
@Published private(set) var isSaving = false
- @Published var errorMessage: String?
+ @Published var listErrorMessage: String?
+ @Published var mutationErrorMessage: String?
func reset() {
tokens = []
isLoading = false
isSaving = false
- errorMessage = nil
+ listErrorMessage = nil
+ mutationErrorMessage = nil
}
func loadTokens(client: NjallaClient) async {
+ guard !isLoading else { return }
+
isLoading = true
defer {
isLoading = false
@@ -25,61 +29,76 @@ final class TokenViewModel: ObservableObject {
tokens = try await client.listTokens().sorted {
tokenLabel(for: $0).localizedCaseInsensitiveCompare(tokenLabel(for: $1)) == .orderedAscending
}
- errorMessage = nil
+ listErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ listErrorMessage = error.userFacingMessage
}
}
func addToken(request: TokenCreateRequest, client: NjallaClient) async -> Bool {
+ guard !isSaving else { return false }
+
isSaving = true
- defer {
- isSaving = false
- }
+ mutationErrorMessage = nil
do {
try await client.addToken(request: request)
- tokens = try await client.listTokens()
- errorMessage = nil
+ isSaving = false
+ Task {
+ await loadTokens(client: client)
+ }
return true
} catch is CancellationError {
+ isSaving = false
return false
} catch {
if (error as? URLError)?.code == .cancelled {
+ isSaving = false
return false
}
- errorMessage = error.localizedDescription
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
return false
}
}
func removeToken(_ token: APIToken, client: NjallaClient) async -> Bool {
+ guard !isSaving else { return false }
+
isSaving = true
- defer {
- isSaving = false
- }
+ mutationErrorMessage = nil
do {
try await client.removeToken(key: token.key)
tokens.removeAll { $0.key == token.key }
- errorMessage = nil
+ isSaving = false
+ Task {
+ await loadTokens(client: client)
+ }
return true
} catch is CancellationError {
+ isSaving = false
return false
} catch {
if (error as? URLError)?.code == .cancelled {
+ isSaving = false
return false
}
- errorMessage = error.localizedDescription
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
return false
}
}
+ func dismissMutationError() {
+ mutationErrorMessage = nil
+ }
+
func tokenLabel(for token: APIToken) -> String {
let trimmedComment = token.comment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedComment.isEmpty {
@@ -88,4 +107,10 @@ final class TokenViewModel: ObservableObject {
return String(token.key.prefix(8))
}
+
+ private func sortedTokens(_ tokens: [APIToken]) -> [APIToken] {
+ tokens.sorted {
+ tokenLabel(for: $0).localizedCaseInsensitiveCompare(tokenLabel(for: $1)) == .orderedAscending
+ }
+ }
}
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)
}