krz/rune

an ios client for njalla

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

5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-14T03:37:22Z

v1.2.0 domain management and wallet expansion

Complete v1.1-aligned domain flows with forwards and glue CRUD, improve API payload resilience, and add wallet transaction/payment views.
Harden error handling and UX states across domain screens, remove out-of-scope DNSSEC/renewal record flows for now, and keep auth/session behavior aligned with token-based usage.
 Rune.xcodeproj/project.pbxproj                     |  12 +-
 .../cmc.xcuserdatad/UserInterfaceState.xcuserstate | Bin 19968 -> 26305 bytes
 Rune/API/Models.swift                              | 201 ++++++++++++++++++++-
 Rune/API/NjallaClient.swift                        |  82 ++++++++-
 Rune/App/RuneApp.swift                             |   5 +-
 Rune/ViewModels/DomainViewModel.swift              |  93 +++++++++-
 Rune/ViewModels/SettingsViewModel.swift            |  20 +-
 Rune/ViewModels/WalletViewModel.swift              |  65 +++++++
 Rune/Views/Domains/DomainDetailView.swift          |   8 +-
 Rune/Views/Domains/DomainEditView.swift            |   8 -
 Rune/Views/Domains/DomainListView.swift            |   6 -
 Rune/Views/Domains/ForwardListView.swift           |   6 -
 Rune/Views/Domains/GlueEditView.swift              | 139 ++++++++++++++
 Rune/Views/Domains/GlueListView.swift              | 139 ++++++++++++++
 Rune/Views/Domains/RecordListView.swift            |   6 -
 Rune/Views/Settings/SettingsView.swift             |  19 +-
 Rune/Views/Settings/WalletPaymentDetailView.swift  |  45 +++++
 Rune/Views/Settings/WalletTransactionsView.swift   |  75 ++++++++
 18 files changed, 878 insertions(+), 51 deletions(-)

diff --git a/Rune.xcodeproj/project.pbxproj b/Rune.xcodeproj/project.pbxproj
index af9980a..7359a64 100644
--- a/Rune.xcodeproj/project.pbxproj
+++ b/Rune.xcodeproj/project.pbxproj
@@ -177,7 +177,7 @@
 			attributes = {
 				BuildIndependentTargetsInParallel = 1;
 				LastSwiftUpdateCheck = 2630;
-				LastUpgradeCheck = 2630;
+				LastUpgradeCheck = 2640;
 				TargetAttributes = {
 					8B9907682F7206E2007853B5 = {
 						CreatedOnToolsVersion = 26.3;
@@ -335,6 +335,7 @@
 				MTL_FAST_MATH = YES;
 				ONLY_ACTIVE_ARCH = YES;
 				SDKROOT = iphoneos;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 			};
@@ -392,6 +393,7 @@
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
 				SDKROOT = iphoneos;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 				SWIFT_COMPILATION_MODE = wholemodule;
 				VALIDATE_PRODUCT = YES;
 			};
@@ -403,7 +405,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 2;
+				CURRENT_PROJECT_VERSION = 3;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -419,7 +421,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.1;
+				MARKETING_VERSION = 1.2.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Rune;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -438,7 +440,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 2;
+				CURRENT_PROJECT_VERSION = 3;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -454,7 +456,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.1;
+				MARKETING_VERSION = 1.2.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Rune;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate b/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate
index dd86bef..fc8169f 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 161e8df..31f928d 100644
--- a/Rune/API/Models.swift
+++ b/Rune/API/Models.swift
@@ -18,8 +18,46 @@ struct TokenListResponse: Codable {
     let tokens: [APIToken]
 }
 
-struct ForwardListResponse: Codable {
+struct ForwardListResponse: Decodable {
     let forwards: [EmailForward]
+
+    enum CodingKeys: String, CodingKey {
+        case forwards
+        case mailforwards
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        let forwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .forwards)
+        let mailForwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .mailforwards)
+        forwards = forwardsValue ?? mailForwardsValue ?? []
+    }
+}
+
+struct GlueListResponse: Decodable {
+    let glue: [GlueRecord]
+
+    enum CodingKeys: String, CodingKey {
+        case glue
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        glue = (try container.decodeIfPresent([GlueRecord].self, forKey: .glue)) ?? []
+    }
+}
+
+struct WalletTransactionListResponse: Decodable {
+    let transactions: [WalletTransaction]
+
+    enum CodingKeys: String, CodingKey {
+        case transactions
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        transactions = (try container.decodeIfPresent([WalletTransaction].self, forKey: .transactions)) ?? []
+    }
 }
 
 struct Domain: Decodable, Identifiable, Hashable {
@@ -28,6 +66,7 @@ struct Domain: Decodable, Identifiable, Hashable {
     let name: String
     let status: String?
     let expiry: String?
+    let renewPrice: Int?
     let autorenew: Bool?
     let mailforwarding: Bool?
     let dnssec: Bool?
@@ -38,6 +77,9 @@ struct Domain: Decodable, Identifiable, Hashable {
         case name
         case status
         case expiry
+        case renewPrice = "renew_price"
+        case renewalPrice = "renewal_price"
+        case price
         case autorenew
         case mailforwarding
         case dnssec
@@ -52,6 +94,10 @@ struct Domain: Decodable, Identifiable, Hashable {
         name = try container.decode(String.self, forKey: .name)
         status = try container.decodeIfPresent(String.self, forKey: .status)
         expiry = try container.decodeIfPresent(String.self, forKey: .expiry)
+        renewPrice =
+            container.decodeLossyIntIfPresent(forKey: .renewPrice) ??
+            container.decodeLossyIntIfPresent(forKey: .renewalPrice) ??
+            container.decodeLossyIntIfPresent(forKey: .price)
         autorenew = try container.decodeIfPresent(Bool.self, forKey: .autorenew)
         mailforwarding = try container.decodeIfPresent(Bool.self, forKey: .mailforwarding)
         dnssec = try container.decodeIfPresent(Bool.self, forKey: .dnssec) ??
@@ -62,6 +108,20 @@ struct Domain: Decodable, Identifiable, Hashable {
     }
 }
 
+private extension KeyedDecodingContainer where K == Domain.CodingKeys {
+    func decodeLossyIntIfPresent(forKey key: K) -> Int? {
+        if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
+            return intValue
+        }
+
+        if let stringValue = try? decodeIfPresent(String.self, forKey: key) {
+            return Int(stringValue)
+        }
+
+        return nil
+    }
+}
+
 struct DNSRecord: Codable, Identifiable, Hashable {
     let id: String
     let domain: String
@@ -183,11 +243,121 @@ struct EmailForward: Codable, Hashable, Identifiable {
     let from: String
     let to: String
 
+    enum CodingKeys: String, CodingKey {
+        case domain
+        case from
+        case to
+    }
+
+    init(domain: String, from: String, to: String) {
+        self.domain = domain
+        self.from = from
+        self.to = to
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        let rawFrom = try container.decode(String.self, forKey: .from).trimmingCharacters(in: .whitespacesAndNewlines)
+        let to = try container.decode(String.self, forKey: .to).trimmingCharacters(in: .whitespacesAndNewlines)
+
+        let decodedDomain = try container.decodeIfPresent(String.self, forKey: .domain)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+
+        if let atIndex = rawFrom.lastIndex(of: "@") {
+            let localPart = String(rawFrom[..<atIndex])
+            let fromDomain = String(rawFrom[rawFrom.index(after: atIndex)...])
+            self.from = localPart.isEmpty ? rawFrom : localPart
+            if let decodedDomain, !decodedDomain.isEmpty {
+                self.domain = decodedDomain
+            } else {
+                self.domain = fromDomain
+            }
+        } else {
+            self.from = rawFrom
+            self.domain = decodedDomain ?? ""
+        }
+
+        self.to = to
+    }
+
     var id: String {
         "\(domain)|\(from)|\(to)"
     }
 }
 
+struct GlueRecord: Codable, Hashable, Identifiable {
+    let domain: String
+    let name: String
+    let address4: String?
+    let address6: String?
+
+    enum CodingKeys: String, CodingKey {
+        case domain
+        case name
+        case address4
+        case address6
+    }
+
+    init(domain: String, name: String, address4: String?, address6: String?) {
+        self.domain = domain
+        self.name = name
+        self.address4 = address4
+        self.address6 = address6
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        domain = (try container.decodeIfPresent(String.self, forKey: .domain)) ?? ""
+        name = try container.decode(String.self, forKey: .name)
+        address4 = try container.decodeIfPresent(String.self, forKey: .address4)
+        address6 = try container.decodeIfPresent(String.self, forKey: .address6)
+    }
+
+    var id: String {
+        "\(domain)|\(name)"
+    }
+
+    func withDomain(_ domain: String) -> GlueRecord {
+        GlueRecord(domain: domain, name: name, address4: address4, address6: address6)
+    }
+}
+
+struct WalletTransaction: Codable, Hashable, Identifiable {
+    let id: String
+    let type: String?
+    let status: String?
+    let amount: Int?
+    let date: String?
+    let details: String?
+
+    enum CodingKeys: String, CodingKey {
+        case id
+        case type
+        case status
+        case amount
+        case date
+        case details = "description"
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = (try? container.decodeLossyString(forKey: .id)) ?? UUID().uuidString
+        type = try container.decodeIfPresent(String.self, forKey: .type)
+        status = try container.decodeIfPresent(String.self, forKey: .status)
+        amount = try container.decodeLossyIntIfPresent(forKey: .amount)
+        date = try container.decodeIfPresent(String.self, forKey: .date)
+        details = try container.decodeIfPresent(String.self, forKey: .details)
+    }
+}
+
+struct WalletPayment: Codable, Hashable {
+    let id: String?
+    let amount: Int?
+    let status: String?
+    let address: String?
+    let url: String?
+}
+
 enum DNSRecordType: String, CaseIterable, Identifiable, Codable {
     case a = "A"
     case aaaa = "AAAA"
@@ -546,3 +716,32 @@ private extension KeyedDecodingContainer where K == DNSRecord.CodingKeys {
         return nil
     }
 }
+
+private extension KeyedDecodingContainer where K == WalletTransaction.CodingKeys {
+    func decodeLossyString(forKey key: K) throws -> String {
+        if let stringValue = try decodeIfPresent(String.self, forKey: key) {
+            return stringValue
+        }
+
+        if let intValue = try decodeIfPresent(Int.self, forKey: key) {
+            return String(intValue)
+        }
+
+        throw DecodingError.keyNotFound(
+            key,
+            DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).")
+        )
+    }
+
+    func decodeLossyIntIfPresent(forKey key: K) throws -> Int? {
+        if let intValue = try decodeIfPresent(Int.self, forKey: key) {
+            return intValue
+        }
+
+        if let stringValue = try decodeIfPresent(String.self, forKey: key) {
+            return Int(stringValue)
+        }
+
+        return nil
+    }
+}
diff --git a/Rune/API/NjallaClient.swift b/Rune/API/NjallaClient.swift
index 8da52cb..f91c924 100644
--- a/Rune/API/NjallaClient.swift
+++ b/Rune/API/NjallaClient.swift
@@ -25,10 +25,17 @@ struct NjallaClient: Sendable, Equatable {
         request.httpBody = try JSONSerialization.data(withJSONObject: body)
 
         let (data, response) = try await URLSession.shared.data(for: request)
-        _ = (response as? HTTPURLResponse)?.statusCode ?? -1
+        let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
+        debugLogRawResponse(method: method, statusCode: statusCode, data: data)
 
         let decoder = JSONDecoder()
-        let envelope = try decoder.decode(RPCResponse<T>.self, from: data)
+        let envelope: RPCResponse<T>
+        do {
+            envelope = try decoder.decode(RPCResponse<T>.self, from: data)
+        } catch {
+            debugLogDecodeFailure(method: method, error: error)
+            throw error
+        }
 
         if let error = envelope.error {
             throw NjallaError.api(message: error.message)
@@ -41,6 +48,19 @@ struct NjallaClient: Sendable, Equatable {
         return result
     }
 
+    private func debugLogRawResponse(method: String, statusCode: Int, data: Data) {
+        #if DEBUG
+        let body = String(data: data, encoding: .utf8) ?? "<non-utf8 body: \(data.count) bytes>"
+        debugPrint("[NjallaClient][\(method)] status=\(statusCode) raw=\(body)")
+        #endif
+    }
+
+    private func debugLogDecodeFailure(method: String, error: Error) {
+        #if DEBUG
+        debugPrint("[NjallaClient][\(method)] decode-failure=\(error.localizedDescription)")
+        #endif
+    }
+
     func listDomains() async throws -> [Domain] {
         let response: DomainListResponse = try await call("list-domains")
         return response.domains
@@ -83,6 +103,51 @@ struct NjallaClient: Sendable, Equatable {
         )
     }
 
+    func listGlue(for domain: String) async throws -> [GlueRecord] {
+        let response: GlueListResponse = try await call("list-glue", params: ["domain": domain])
+        return response.glue.map { record in
+            record.domain.isEmpty ? record.withDomain(domain) : record
+        }
+    }
+
+    func addGlue(for domain: String, name: String, address4: String?, address6: String?) async throws {
+        var params: [String: Any] = [
+            "domain": domain,
+            "name": name
+        ]
+        if let address4, !address4.isEmpty {
+            params["address4"] = address4
+        }
+        if let address6, !address6.isEmpty {
+            params["address6"] = address6
+        }
+        let _: EmptyResult = try await call("add-glue", params: params)
+    }
+
+    func editGlue(for domain: String, name: String, address4: String?, address6: String?) async throws {
+        var params: [String: Any] = [
+            "domain": domain,
+            "name": name
+        ]
+        if let address4, !address4.isEmpty {
+            params["address4"] = address4
+        }
+        if let address6, !address6.isEmpty {
+            params["address6"] = address6
+        }
+        let _: EmptyResult = try await call("edit-glue", params: params)
+    }
+
+    func removeGlue(for domain: String, name: String) async throws {
+        let _: EmptyResult = try await call(
+            "remove-glue",
+            params: [
+                "domain": domain,
+                "name": name
+            ]
+        )
+    }
+
     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
@@ -124,9 +189,22 @@ struct NjallaClient: Sendable, Equatable {
         let _: EmptyResult = try await call("remove-token", params: ["key": key])
     }
 
+    func logout() async throws {
+        let _: EmptyResult = try await call("logout")
+    }
+
     func getBalance() async throws -> WalletBalance {
         try await call("get-balance")
     }
+
+    func listTransactions() async throws -> [WalletTransaction] {
+        let response: WalletTransactionListResponse = try await call("list-transactions")
+        return response.transactions
+    }
+
+    func getPayment(id: String) async throws -> WalletPayment {
+        try await call("get-payment", params: ["id": id])
+    }
 }
 
 enum NjallaError: LocalizedError {
diff --git a/Rune/App/RuneApp.swift b/Rune/App/RuneApp.swift
index 1709532..c826363 100644
--- a/Rune/App/RuneApp.swift
+++ b/Rune/App/RuneApp.swift
@@ -13,6 +13,7 @@ private struct RootView: View {
     @StateObject private var settingsViewModel = SettingsViewModel()
     @StateObject private var domainViewModel = DomainViewModel()
     @StateObject private var tokenViewModel = TokenViewModel()
+    @StateObject private var walletViewModel = WalletViewModel()
     @State private var selectedTab = 0
 
     var body: some View {
@@ -38,9 +39,10 @@ private struct RootView: View {
                 }
                 .tag(1)
 
-            SettingsView(viewModel: settingsViewModel) {
+            SettingsView(viewModel: settingsViewModel, walletViewModel: walletViewModel) {
                 domainViewModel.reset()
                 tokenViewModel.reset()
+                walletViewModel.reset()
                 selectedTab = 0
             }
             .tabItem {
@@ -66,6 +68,7 @@ private struct RootView: View {
         guard let client = settingsViewModel.client else {
             domainViewModel.reset()
             tokenViewModel.reset()
+            walletViewModel.reset()
             selectedTab = 0
             return
         }
diff --git a/Rune/ViewModels/DomainViewModel.swift b/Rune/ViewModels/DomainViewModel.swift
index 1da1bbe..37fb7a1 100644
--- a/Rune/ViewModels/DomainViewModel.swift
+++ b/Rune/ViewModels/DomainViewModel.swift
@@ -7,16 +7,19 @@ final class DomainViewModel: ObservableObject {
     @Published private(set) var selectedDomain: Domain?
     @Published private(set) var records: [DNSRecord] = []
     @Published private(set) var forwards: [EmailForward] = []
+    @Published private(set) var glueRecords: [GlueRecord] = []
     @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 isLoadingGlue = false
     @Published private(set) var isSaving = false
     @Published private(set) var hasLoadedDomains = false
     @Published var domainsErrorMessage: String?
     @Published var detailErrorMessage: String?
     @Published var recordsErrorMessage: String?
     @Published var forwardsErrorMessage: String?
+    @Published var glueErrorMessage: String?
     @Published var mutationErrorMessage: String?
 
     private var recordsRefreshTask: Task<Void, Never>?
@@ -26,16 +29,19 @@ final class DomainViewModel: ObservableObject {
         selectedDomain = nil
         records = []
         forwards = []
+        glueRecords = []
         isLoadingDomains = false
         isLoadingDetail = false
         isLoadingRecords = false
         isLoadingForwards = false
+        isLoadingGlue = false
         isSaving = false
         hasLoadedDomains = false
         domainsErrorMessage = nil
         detailErrorMessage = nil
         recordsErrorMessage = nil
         forwardsErrorMessage = nil
+        glueErrorMessage = nil
         mutationErrorMessage = nil
         stopAutoRefreshRecords()
     }
@@ -167,6 +173,85 @@ final class DomainViewModel: ObservableObject {
         }
     }
 
+    func loadGlue(for domain: String, client: NjallaClient) async {
+        guard !isLoadingGlue else { return }
+
+        isLoadingGlue = true
+        defer {
+            isLoadingGlue = false
+        }
+
+        do {
+            glueRecords = try await client.listGlue(for: domain).sorted { $0.name < $1.name }
+            glueErrorMessage = nil
+        } catch is CancellationError {
+            return
+        } catch {
+            if (error as? URLError)?.code == .cancelled {
+                return
+            }
+            glueErrorMessage = error.userFacingMessage
+        }
+    }
+
+    func addGlue(for domain: String, name: String, address4: String?, address6: String?, client: NjallaClient) async throws {
+        guard !isSaving else { return }
+
+        isSaving = true
+        mutationErrorMessage = nil
+
+        do {
+            try await client.addGlue(for: domain, name: name, address4: address4, address6: address6)
+            isSaving = false
+            Task {
+                await loadGlue(for: domain, client: client)
+            }
+        } catch {
+            isSaving = false
+            mutationErrorMessage = error.userFacingMessage
+            throw error
+        }
+    }
+
+    func editGlue(for domain: String, name: String, address4: String?, address6: String?, client: NjallaClient) async throws {
+        guard !isSaving else { return }
+
+        isSaving = true
+        mutationErrorMessage = nil
+
+        do {
+            try await client.editGlue(for: domain, name: name, address4: address4, address6: address6)
+            isSaving = false
+            Task {
+                await loadGlue(for: domain, client: client)
+            }
+        } catch {
+            isSaving = false
+            mutationErrorMessage = error.userFacingMessage
+            throw error
+        }
+    }
+
+    func removeGlue(_ record: GlueRecord, client: NjallaClient) async throws {
+        guard !isSaving else { return }
+
+        isSaving = true
+        mutationErrorMessage = nil
+
+        do {
+            try await client.removeGlue(for: record.domain, name: record.name)
+            glueRecords.removeAll { $0.id == record.id }
+            isSaving = false
+            Task {
+                await loadGlue(for: record.domain, client: client)
+            }
+        } catch {
+            isSaving = false
+            mutationErrorMessage = error.userFacingMessage
+            throw error
+        }
+    }
+
     private func fetchRecords(for domain: String, client: NjallaClient) async {
         guard !isLoadingRecords else { return }
 
@@ -255,10 +340,12 @@ final class DomainViewModel: ObservableObject {
 
         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.")
+            if updatedRecords.contains(where: { $0.id == record.id }) {
+                // Some API responses can be eventually consistent; treat successful call as authoritative.
+                records.removeAll { $0.id == record.id }
+            } else {
+                records = sortedRecords(updatedRecords)
             }
-            records = sortedRecords(updatedRecords)
             recordsErrorMessage = nil
             isSaving = false
             Task {
diff --git a/Rune/ViewModels/SettingsViewModel.swift b/Rune/ViewModels/SettingsViewModel.swift
index 6b773eb..c253ab8 100644
--- a/Rune/ViewModels/SettingsViewModel.swift
+++ b/Rune/ViewModels/SettingsViewModel.swift
@@ -83,12 +83,12 @@ final class SettingsViewModel: ObservableObject {
         }
     }
 
-    func logout() throws {
+    func logout() async throws {
+        if let client {
+            try await client.logout()
+        }
         try keychainManager.deleteToken()
-        client = nil
-        balance = nil
-        isAuthenticated = false
-        errorMessage = nil
+        clearLocalSession()
     }
 
     func logoutIfCurrentTokenDeleted(_ key: String) throws -> Bool {
@@ -96,7 +96,15 @@ final class SettingsViewModel: ObservableObject {
             return false
         }
 
-        try logout()
+        try keychainManager.deleteToken()
+        clearLocalSession()
         return true
     }
+
+    private func clearLocalSession() {
+        client = nil
+        balance = nil
+        isAuthenticated = false
+        errorMessage = nil
+    }
 }
diff --git a/Rune/ViewModels/WalletViewModel.swift b/Rune/ViewModels/WalletViewModel.swift
new file mode 100644
index 0000000..cbe0538
--- /dev/null
+++ b/Rune/ViewModels/WalletViewModel.swift
@@ -0,0 +1,65 @@
+import Combine
+import Foundation
+
+@MainActor
+final class WalletViewModel: ObservableObject {
+    @Published private(set) var transactions: [WalletTransaction] = []
+    @Published private(set) var selectedPayment: WalletPayment?
+    @Published private(set) var isLoadingTransactions = false
+    @Published private(set) var isLoadingPayment = false
+    @Published var transactionsErrorMessage: String?
+    @Published var paymentErrorMessage: String?
+
+    func reset() {
+        transactions = []
+        selectedPayment = nil
+        isLoadingTransactions = false
+        isLoadingPayment = false
+        transactionsErrorMessage = nil
+        paymentErrorMessage = nil
+    }
+
+    func loadTransactions(client: NjallaClient) async {
+        guard !isLoadingTransactions else { return }
+
+        isLoadingTransactions = true
+        defer {
+            isLoadingTransactions = false
+        }
+
+        do {
+            transactions = try await client.listTransactions().sorted {
+                ($0.date ?? "", $0.id) > ($1.date ?? "", $1.id)
+            }
+            transactionsErrorMessage = nil
+        } catch is CancellationError {
+            return
+        } catch {
+            if (error as? URLError)?.code == .cancelled {
+                return
+            }
+            transactionsErrorMessage = error.userFacingMessage
+        }
+    }
+
+    func loadPayment(id: String, client: NjallaClient) async {
+        guard !isLoadingPayment else { return }
+
+        isLoadingPayment = true
+        defer {
+            isLoadingPayment = false
+        }
+
+        do {
+            selectedPayment = try await client.getPayment(id: id)
+            paymentErrorMessage = nil
+        } catch is CancellationError {
+            return
+        } catch {
+            if (error as? URLError)?.code == .cancelled {
+                return
+            }
+            paymentErrorMessage = error.userFacingMessage
+        }
+    }
+}
diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift
index b4ee00d..7fc811a 100644
--- a/Rune/Views/Domains/DomainDetailView.swift
+++ b/Rune/Views/Domains/DomainDetailView.swift
@@ -35,7 +35,11 @@ struct DomainDetailView: View {
                         NavigationLink("Records") {
                             RecordListView(domainName: domain.name, viewModel: viewModel, client: client)
                         }
+                        NavigationLink("Glue Records") {
+                            GlueListView(domainName: domain.name, viewModel: viewModel, client: client)
+                        }
                     }
+
                 }
                 .listStyle(.insetGrouped)
                 .toolbar {
@@ -79,11 +83,11 @@ struct DomainDetailView: View {
 
     private func nameserverText(_ nameservers: [String]?) -> String {
         guard let nameservers else {
-            return "Not available"
+            return "Njalla"
         }
 
         guard !nameservers.isEmpty else {
-            return "Default"
+            return "Njalla"
         }
 
         return nameservers.joined(separator: ", ")
diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift
index 0b3b1f8..a5fd99b 100644
--- a/Rune/Views/Domains/DomainEditView.swift
+++ b/Rune/Views/Domains/DomainEditView.swift
@@ -65,14 +65,6 @@ struct DomainEditView: View {
                     .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) {}
diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift
index 68f2ede..ad8134a 100644
--- a/Rune/Views/Domains/DomainListView.swift
+++ b/Rune/Views/Domains/DomainListView.swift
@@ -61,12 +61,6 @@ struct DomainListView: View {
         .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/ForwardListView.swift b/Rune/Views/Domains/ForwardListView.swift
index 4e20ab2..6846a79 100644
--- a/Rune/Views/Domains/ForwardListView.swift
+++ b/Rune/Views/Domains/ForwardListView.swift
@@ -88,12 +88,6 @@ struct ForwardListView: View {
             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 }
diff --git a/Rune/Views/Domains/GlueEditView.swift b/Rune/Views/Domains/GlueEditView.swift
new file mode 100644
index 0000000..b56a5d7
--- /dev/null
+++ b/Rune/Views/Domains/GlueEditView.swift
@@ -0,0 +1,139 @@
+import SwiftUI
+
+struct GlueEditView: View {
+    let domainName: String
+    let existingRecord: GlueRecord?
+    @ObservedObject var viewModel: DomainViewModel
+    let client: NjallaClient
+
+    @Environment(\.dismiss) private var dismiss
+    @State private var name: String
+    @State private var address4: String
+    @State private var address6: String
+    @State private var localErrorMessage: String?
+
+    init(domainName: String, existingRecord: GlueRecord?, viewModel: DomainViewModel, client: NjallaClient) {
+        self.domainName = domainName
+        self.existingRecord = existingRecord
+        self.viewModel = viewModel
+        self.client = client
+        _name = State(initialValue: existingRecord?.name ?? "")
+        _address4 = State(initialValue: existingRecord?.address4 ?? "")
+        _address6 = State(initialValue: existingRecord?.address6 ?? "")
+    }
+
+    var body: some View {
+        Form {
+            Section("Record") {
+                TextField("Name", text: $name)
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+                    .disabled(existingRecord != nil)
+
+                TextField("IPv4", text: $address4)
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+
+                TextField("IPv6", text: $address6)
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+            }
+
+            Section {
+                Button("Save") {
+                    Task {
+                        await save()
+                    }
+                }
+                .disabled(viewModel.isSaving)
+            }
+        }
+        .navigationTitle(existingRecord == nil ? "Add Glue" : "Edit Glue")
+        .navigationBarTitleDisplayMode(.inline)
+        .overlay {
+            if viewModel.isSaving {
+                ProgressView()
+                    .controlSize(.large)
+            }
+        }
+        .toolbar {
+            ToolbarItem(placement: .cancellationAction) {
+                Button("Cancel", role: .cancel) {
+                    dismiss()
+                }
+                .disabled(viewModel.isSaving)
+            }
+        }
+        .alert("Invalid Glue Data", isPresented: localErrorBinding) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(localErrorMessage ?? "")
+        }
+        .alert("Request Failed", isPresented: mutationErrorBinding) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(viewModel.mutationErrorMessage ?? "")
+        }
+    }
+
+    private func save() async {
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedAddress4 = address4.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedAddress6 = address6.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        guard !trimmedName.isEmpty else {
+            localErrorMessage = "Glue record name is required."
+            return
+        }
+
+        guard !trimmedAddress4.isEmpty || !trimmedAddress6.isEmpty else {
+            localErrorMessage = "Provide at least one address (IPv4 or IPv6)."
+            return
+        }
+
+        do {
+            if existingRecord == nil {
+                try await viewModel.addGlue(
+                    for: domainName,
+                    name: trimmedName,
+                    address4: trimmedAddress4.isEmpty ? nil : trimmedAddress4,
+                    address6: trimmedAddress6.isEmpty ? nil : trimmedAddress6,
+                    client: client
+                )
+            } else {
+                try await viewModel.editGlue(
+                    for: domainName,
+                    name: trimmedName,
+                    address4: trimmedAddress4.isEmpty ? nil : trimmedAddress4,
+                    address6: trimmedAddress6.isEmpty ? nil : trimmedAddress6,
+                    client: client
+                )
+            }
+            dismiss()
+        } catch {
+            return
+        }
+    }
+
+    private var localErrorBinding: Binding<Bool> {
+        Binding(
+            get: { localErrorMessage != nil },
+            set: { newValue in
+                if !newValue {
+                    localErrorMessage = nil
+                }
+            }
+        )
+    }
+
+    private var mutationErrorBinding: Binding<Bool> {
+        Binding(
+            get: { viewModel.mutationErrorMessage != nil },
+            set: { newValue in
+                if !newValue {
+                    viewModel.dismissMutationError()
+                }
+            }
+        )
+    }
+}
diff --git a/Rune/Views/Domains/GlueListView.swift b/Rune/Views/Domains/GlueListView.swift
new file mode 100644
index 0000000..b390a84
--- /dev/null
+++ b/Rune/Views/Domains/GlueListView.swift
@@ -0,0 +1,139 @@
+import SwiftUI
+
+struct GlueListView: View {
+    let domainName: String
+    @ObservedObject var viewModel: DomainViewModel
+    let client: NjallaClient
+
+    @State private var showingAddGlue = false
+    @State private var recordPendingDeletion: GlueRecord?
+
+    var body: some View {
+        List {
+            if let errorMessage = viewModel.glueErrorMessage {
+                Section {
+                    InlineErrorView(message: errorMessage, retryTitle: "Retry Glue Records") {
+                        Task {
+                            await viewModel.loadGlue(for: domainName, client: client)
+                        }
+                    }
+                    .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+                }
+            }
+
+            if viewModel.isLoadingGlue && viewModel.glueRecords.isEmpty {
+                Section {
+                    HStack {
+                        Spacer()
+                        ProgressView("Loading Glue")
+                        Spacer()
+                    }
+                }
+            } else if viewModel.glueRecords.isEmpty {
+                Section {
+                    ContentUnavailableView(
+                        "No Glue Records",
+                        systemImage: "list.bullet.rectangle",
+                        description: Text("No glue records are configured for this domain.")
+                    )
+                }
+            } else {
+                ForEach(viewModel.glueRecords) { record in
+                    NavigationLink {
+                        GlueEditView(domainName: domainName, existingRecord: record, viewModel: viewModel, client: client)
+                    } label: {
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text(record.name)
+                                .font(.headline)
+                            Text("IPv4: \(record.address4 ?? "n/a")")
+                                .font(.subheadline)
+                                .foregroundStyle(.secondary)
+                            Text("IPv6: \(record.address6 ?? "n/a")")
+                                .font(.subheadline)
+                                .foregroundStyle(.secondary)
+                        }
+                        .padding(.vertical, 4)
+                    }
+                    .swipeActions {
+                        Button("Delete", role: .destructive) {
+                            recordPendingDeletion = record
+                        }
+                    }
+                    .disabled(viewModel.isSaving)
+                }
+            }
+        }
+        .listStyle(.insetGrouped)
+        .navigationTitle("Glue Records")
+        .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            Button {
+                showingAddGlue = true
+            } label: {
+                Label("Add Glue", systemImage: "plus")
+            }
+            .disabled(viewModel.isSaving)
+        }
+        .sheet(isPresented: $showingAddGlue) {
+            NavigationStack {
+                GlueEditView(domainName: domainName, existingRecord: nil, viewModel: viewModel, client: client)
+            }
+        }
+        .task {
+            await viewModel.loadGlue(for: domainName, client: client)
+        }
+        .refreshable {
+            await viewModel.loadGlue(for: domainName, client: client)
+        }
+        .alert(deleteAlertTitle, isPresented: deleteBinding) {
+            Button("Delete Glue", role: .destructive) {
+                guard let recordPendingDeletion else { return }
+                Task {
+                    do {
+                        try await viewModel.removeGlue(recordPendingDeletion, client: client)
+                        self.recordPendingDeletion = nil
+                    } catch {
+                        return
+                    }
+                }
+            }
+            Button("Cancel", role: .cancel) {
+                recordPendingDeletion = nil
+            }
+        } message: {
+            Text("Delete glue record \(recordPendingDeletion?.name ?? "")?")
+        }
+        .alert("Request Failed", isPresented: mutationErrorBinding) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(viewModel.mutationErrorMessage ?? "")
+        }
+    }
+
+    private var deleteAlertTitle: String {
+        guard let recordPendingDeletion else { return "" }
+        return "Delete glue record \(recordPendingDeletion.name)?"
+    }
+
+    private var deleteBinding: Binding<Bool> {
+        Binding(
+            get: { recordPendingDeletion != nil },
+            set: { newValue in
+                if !newValue {
+                    recordPendingDeletion = nil
+                }
+            }
+        )
+    }
+
+    private var mutationErrorBinding: Binding<Bool> {
+        Binding(
+            get: { viewModel.mutationErrorMessage != nil },
+            set: { newValue in
+                if !newValue {
+                    viewModel.dismissMutationError()
+                }
+            }
+        )
+    }
+}
diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift
index 6c40ab0..74ab286 100644
--- a/Rune/Views/Domains/RecordListView.swift
+++ b/Rune/Views/Domains/RecordListView.swift
@@ -73,12 +73,6 @@ struct RecordListView: View {
         .refreshable {
             await viewModel.loadRecords(for: domainName, client: client)
         }
-        .overlay(alignment: .top) {
-            if viewModel.isLoadingRecords && !viewModel.records.isEmpty {
-                ProgressView()
-                    .padding(.top, 8)
-            }
-        }
         .alert("Request Failed", isPresented: mutationErrorBinding) {
             Button("OK", role: .cancel) {}
         } message: {
diff --git a/Rune/Views/Settings/SettingsView.swift b/Rune/Views/Settings/SettingsView.swift
index 4d07887..a776956 100644
--- a/Rune/Views/Settings/SettingsView.swift
+++ b/Rune/Views/Settings/SettingsView.swift
@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SettingsView: View {
     @ObservedObject var viewModel: SettingsViewModel
+    @ObservedObject var walletViewModel: WalletViewModel
     let onLogout: () -> Void
     @State private var showingLogoutConfirmation = false
 
@@ -33,6 +34,12 @@ struct SettingsView: View {
                         }
                     }
                     .disabled(viewModel.client == nil || viewModel.isLoadingBalance)
+
+                    if let client = viewModel.client {
+                        NavigationLink("Transactions") {
+                            WalletTransactionsView(viewModel: walletViewModel, client: client)
+                        }
+                    }
                 }
 
                 Section("Account") {
@@ -57,11 +64,13 @@ struct SettingsView: View {
             titleVisibility: .visible
         ) {
             Button("Log Out", role: .destructive) {
-                do {
-                    try viewModel.logout()
-                    onLogout()
-                } catch {
-                    viewModel.errorMessage = error.localizedDescription
+                Task {
+                    do {
+                        try await viewModel.logout()
+                        onLogout()
+                    } catch {
+                        viewModel.errorMessage = error.localizedDescription
+                    }
                 }
             }
 
diff --git a/Rune/Views/Settings/WalletPaymentDetailView.swift b/Rune/Views/Settings/WalletPaymentDetailView.swift
new file mode 100644
index 0000000..f5ef605
--- /dev/null
+++ b/Rune/Views/Settings/WalletPaymentDetailView.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct WalletPaymentDetailView: View {
+    let transactionID: String
+    @ObservedObject var viewModel: WalletViewModel
+    let client: NjallaClient
+
+    var body: some View {
+        Group {
+            if viewModel.isLoadingPayment && viewModel.selectedPayment == nil {
+                ProgressView("Loading Payment")
+            } else if let payment = viewModel.selectedPayment {
+                List {
+                    Section("Payment") {
+                        detailRow(label: "ID", value: payment.id ?? transactionID)
+                        detailRow(label: "Status", value: payment.status ?? "Not available")
+                        detailRow(label: "Amount", value: payment.amount.map { "€\($0)" } ?? "Not available")
+                        detailRow(label: "Address", value: payment.address ?? "Not available")
+                        detailRow(label: "URL", value: payment.url ?? "Not available")
+                    }
+                }
+                .listStyle(.insetGrouped)
+            } else if let errorMessage = viewModel.paymentErrorMessage {
+                ContentUnavailableView("Payment Unavailable", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
+            } else {
+                ContentUnavailableView("Payment Unavailable", systemImage: "creditcard", description: Text("No payment details were returned for this transaction."))
+            }
+        }
+        .navigationTitle("Payment")
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            await viewModel.loadPayment(id: transactionID, client: client)
+        }
+    }
+
+    private func detailRow(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+            Spacer()
+            Text(value)
+                .foregroundStyle(.secondary)
+                .multilineTextAlignment(.trailing)
+        }
+    }
+}
diff --git a/Rune/Views/Settings/WalletTransactionsView.swift b/Rune/Views/Settings/WalletTransactionsView.swift
new file mode 100644
index 0000000..f0106de
--- /dev/null
+++ b/Rune/Views/Settings/WalletTransactionsView.swift
@@ -0,0 +1,75 @@
+import SwiftUI
+
+struct WalletTransactionsView: View {
+    @ObservedObject var viewModel: WalletViewModel
+    let client: NjallaClient
+
+    var body: some View {
+        List {
+            if let errorMessage = viewModel.transactionsErrorMessage {
+                Section {
+                    InlineErrorView(message: errorMessage, retryTitle: "Retry Transactions") {
+                        Task {
+                            await viewModel.loadTransactions(client: client)
+                        }
+                    }
+                    .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+                }
+            }
+
+            if viewModel.isLoadingTransactions && viewModel.transactions.isEmpty {
+                Section {
+                    HStack {
+                        Spacer()
+                        ProgressView("Loading Transactions")
+                        Spacer()
+                    }
+                }
+            } else if viewModel.transactions.isEmpty {
+                Section {
+                    ContentUnavailableView(
+                        "No Transactions",
+                        systemImage: "eurosign.circle",
+                        description: Text("No wallet transactions were returned for this account.")
+                    )
+                }
+            } else {
+                ForEach(viewModel.transactions) { transaction in
+                    NavigationLink {
+                        WalletPaymentDetailView(transactionID: transaction.id, viewModel: viewModel, client: client)
+                    } label: {
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text(transaction.type ?? "Transaction")
+                                .font(.headline)
+                            Text(transactionDateText(transaction))
+                                .font(.subheadline)
+                                .foregroundStyle(.secondary)
+                            if let amount = transaction.amount {
+                                Text("Amount: €\(amount)")
+                                    .font(.subheadline)
+                                    .foregroundStyle(.secondary)
+                            }
+                        }
+                        .padding(.vertical, 4)
+                    }
+                }
+            }
+        }
+        .listStyle(.insetGrouped)
+        .navigationTitle("Transactions")
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            await viewModel.loadTransactions(client: client)
+        }
+        .refreshable {
+            await viewModel.loadTransactions(client: client)
+        }
+    }
+
+    private func transactionDateText(_ transaction: WalletTransaction) -> String {
+        if let date = transaction.date, !date.isEmpty {
+            return date
+        }
+        return "ID: \(transaction.id)"
+    }
+}