krz/rune

an ios client for njalla

clone: git clone https://gitbay.org/krz/rune.git

fcf864a15b70e4ecb5bd789b1db3116221c34394

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-11T16:48:40Z

add FUNDING.yml
 .github/FUNDING.yml                                |   1 +
 Rune.xcodeproj/project.pbxproj                     |  24 +-
 .../cmc.xcuserdatad/UserInterfaceState.xcuserstate | Bin 16943 -> 19968 bytes
 Rune/API/Models.swift                              | 201 +++++++++++++++--
 Rune/API/NjallaClient.swift                        |  63 +++++-
 Rune/App/RuneApp.swift                             |  25 ++-
 Rune/ViewModels/DomainViewModel.swift              | 248 ++++++++++++++++++---
 Rune/ViewModels/TokenViewModel.swift               |  55 +++--
 Rune/Views/Domains/DomainDetailView.swift          |  28 ++-
 Rune/Views/Domains/DomainEditView.swift            | 117 ++++++++--
 Rune/Views/Domains/DomainListView.swift            |  71 +++---
 Rune/Views/Domains/ForwardAddView.swift            | 111 +++++++++
 Rune/Views/Domains/ForwardListView.swift           | 171 ++++++++++++++
 Rune/Views/Domains/RecordAddView.swift             |  37 ++-
 Rune/Views/Domains/RecordEditView.swift            |  51 +++--
 Rune/Views/Domains/RecordListView.swift            |  55 ++++-
 Rune/Views/Shared/FeedbackViews.swift              |  38 ++++
 Rune/Views/Tokens/TokenAddView.swift               |  98 +++++++-
 Rune/Views/Tokens/TokenListView.swift              |  92 ++++++--
 RuneTests/RuneTests.swift                          |  82 ++++++-
 20 files changed, 1354 insertions(+), 214 deletions(-)

diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..4b822e5
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: [ccleberg]
diff --git a/Rune.xcodeproj/project.pbxproj b/Rune.xcodeproj/project.pbxproj
index b45406b..af9980a 100644
--- a/Rune.xcodeproj/project.pbxproj
+++ b/Rune.xcodeproj/project.pbxproj
@@ -8,7 +8,6 @@
 
 /* Begin PBXBuildFile section */
 		8B9907952F7206FB007853B5 /* API.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B9907932F7206FB007853B5 /* API.md */; };
-		8B9907962F7206FB007853B5 /* PLANNING.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B9907942F7206FB007853B5 /* PLANNING.md */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -33,7 +32,6 @@
 		8B9907762F7206E3007853B5 /* RuneTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RuneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		8B9907802F7206E3007853B5 /* RuneUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RuneUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		8B9907932F7206FB007853B5 /* API.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = API.md; sourceTree = "<group>"; };
-		8B9907942F7206FB007853B5 /* PLANNING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = PLANNING.md; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -83,7 +81,6 @@
 			isa = PBXGroup;
 			children = (
 				8B9907932F7206FB007853B5 /* API.md */,
-				8B9907942F7206FB007853B5 /* PLANNING.md */,
 				8B99076B2F7206E2007853B5 /* Rune */,
 				8B9907792F7206E3007853B5 /* RuneTests */,
 				8B9907832F7206E3007853B5 /* RuneUITests */,
@@ -222,7 +219,6 @@
 			buildActionMask = 2147483647;
 			files = (
 				8B9907952F7206FB007853B5 /* API.md in Resources */,
-				8B9907962F7206FB007853B5 /* PLANNING.md in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -407,20 +403,23 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 2;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = Rune;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Rune;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -439,20 +438,23 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 2;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = Rune;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Rune;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -473,7 +475,7 @@
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.RuneTests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -495,7 +497,7 @@
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.RuneTests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -516,6 +518,7 @@
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.RuneUITests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -536,6 +539,7 @@
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.RuneUITests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate b/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate
index c59008e..dd86bef 100644
Binary files a/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate and b/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate differ
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 {
@@ -46,6 +50,36 @@ struct TokenAddView: View {
                 Text("Enter one API method per line, for example `list-domains`.")
             }
 
+            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 {
@@ -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)
     }
diff --git a/RuneTests/RuneTests.swift b/RuneTests/RuneTests.swift
index 16abf59..fa8c2ef 100644
--- a/RuneTests/RuneTests.swift
+++ b/RuneTests/RuneTests.swift
@@ -8,10 +8,88 @@
 import Testing
 @testable import Rune
 
+@MainActor
 struct RuneTests {
 
-    @Test func example() async throws {
-        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+    @Test func domainUpdateRequestIncludesOnlyChangedFields() async throws {
+        let request = DomainUpdateRequest(
+            autorenew: true,
+            mailforwarding: nil,
+            dnssec: nil,
+            lock: false,
+            nameservers: nil
+        )
+
+        #expect(request.hasChanges)
+        #expect(request.params["autorenew"] as? Bool == true)
+        #expect(request.params["lock"] as? Bool == false)
+        #expect(request.params["dnssec"] == nil)
+        #expect(request.params["mailforwarding"] == nil)
+        #expect(request.params["nameservers"] == nil)
+    }
+
+    @Test func changingLockDoesNotAlterDNSSEC() async throws {
+        let request = DomainUpdateRequest(
+            autorenew: nil,
+            mailforwarding: nil,
+            dnssec: nil,
+            lock: true,
+            nameservers: nil
+        )
+
+        #expect(request.hasChanges)
+        #expect(request.params.count == 1)
+        #expect(request.params["lock"] as? Bool == true)
+        #expect(request.params["dnssec"] == nil)
+    }
+
+    @Test func dnssecOnlyChangeSendsOnlyDNSSEC() async throws {
+        let request = DomainUpdateRequest(
+            autorenew: nil,
+            mailforwarding: nil,
+            dnssec: false,
+            lock: nil,
+            nameservers: nil
+        )
+
+        #expect(request.hasChanges)
+        #expect(request.params.count == 1)
+        #expect(request.params["dnssec"] as? Bool == false)
+        #expect(request.params["lock"] == nil)
+        #expect(request.params["autorenew"] == nil)
+        #expect(request.params["mailforwarding"] == nil)
     }
 
+    @Test func ttlPresetUsesPresetSeconds() async throws {
+        var draft = DNSRecordDraft()
+        draft.type = .a
+        draft.name = "www"
+        draft.ttlSeconds = TTLPreset.fifteenMinutes.rawValue
+
+        let params = draft.params(domain: "example.com")
+
+        #expect(params["ttl"] as? Int == 900)
+    }
+
+    @Test func ttlOptionsIncludeExistingCustomValue() async throws {
+        let record = DNSRecord(
+            id: "1",
+            domain: "example.com",
+            type: "A",
+            name: "www",
+            content: "1.2.3.4",
+            ttl: 42,
+            prio: nil,
+            weight: nil,
+            port: nil,
+            target: nil,
+            sshAlgorithm: nil,
+            sshType: nil
+        )
+
+        let draft = DNSRecordDraft(record: record)
+
+        #expect(draft.ttlSeconds == 42)
+        #expect(draft.ttlOptions.contains(where: { $0.seconds == 42 && $0.isCustom }))
+    }
 }