From fcf864a15b70e4ecb5bd789b1db3116221c34394 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 11 Apr 2026 11:48:40 -0500 Subject: add FUNDING.yml --- 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 ++++++++--- 16 files changed, 1259 insertions(+), 202 deletions(-) create mode 100644 Rune/Views/Domains/ForwardAddView.swift create mode 100644 Rune/Views/Domains/ForwardListView.swift create mode 100644 Rune/Views/Shared/FeedbackViews.swift (limited to 'Rune') 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.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: 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 { 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? 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 { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.detailErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.detailErrorMessage = nil + } + } + ) + } + + private var mutationErrorBinding: Binding { + 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 { + Binding( + get: { nameserversText }, + set: { newValue in + nameserversText = newValue + nameserversDirty = normalizedNameservers(from: newValue) != originalNameservers + } + ) + } + + private func dirtyBinding( + for value: Binding, + dirty: Binding, + original: Bool + ) -> Binding { + 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 { - 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 { + 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 { + Binding( + get: { forwardPendingDeletion != nil }, + set: { newValue in + if !newValue { + forwardPendingDeletion = nil + } + } + ) + } + + private var mutationErrorBinding: Binding { + 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 { + private var mutationErrorBinding: Binding { 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 { + private var mutationErrorBinding: Binding { 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 { + private var mutationErrorBinding: Binding { 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 { 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 { + private var mutationErrorBinding: Binding { 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) } -- cgit v1.2.3