diff options
Diffstat (limited to 'Rune')
20 files changed, 2111 insertions, 0 deletions
diff --git a/Rune/API/Models.swift b/Rune/API/Models.swift new file mode 100644 index 0000000..f0209b4 --- /dev/null +++ b/Rune/API/Models.swift @@ -0,0 +1,385 @@ +import Foundation + +struct EmptyResult: Codable {} + +struct WalletBalance: Codable, Equatable { + let balance: Int +} + +struct DomainListResponse: Codable { + let domains: [Domain] +} + +struct RecordListResponse: Codable { + let records: [DNSRecord] +} + +struct TokenListResponse: Codable { + let tokens: [APIToken] +} + +struct Domain: Codable, Identifiable, Hashable { + var id: String { name } + + let name: String + let status: String? + let expiry: String? + let autorenew: Bool? + let mailforwarding: Bool? + let dnssec: Bool? + let lock: Bool? + let nameservers: [String]? +} + +struct DNSRecord: Codable, Identifiable, Hashable { + let id: String + let domain: String + let type: String + let name: String + let content: String? + let ttl: Int? + let prio: Int? + let weight: Int? + let port: Int? + let target: String? + let sshAlgorithm: Int? + let sshType: Int? + + enum CodingKeys: String, CodingKey { + case id + case domain + case type + case name + case content + case ttl + case prio + case weight + case port + case target + case sshAlgorithm = "ssh_algorithm" + case sshType = "ssh_type" + } + + init( + id: String, + domain: String, + type: String, + name: String, + content: String?, + ttl: Int?, + prio: Int?, + weight: Int?, + port: Int?, + target: String?, + sshAlgorithm: Int?, + sshType: Int? + ) { + self.id = id + self.domain = domain + self.type = type + self.name = name + self.content = content + self.ttl = ttl + self.prio = prio + self.weight = weight + self.port = port + self.target = target + self.sshAlgorithm = sshAlgorithm + self.sshType = sshType + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decodeLossyString(forKey: .id) + domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "" + type = try container.decode(String.self, forKey: .type) + name = try container.decode(String.self, forKey: .name) + content = try container.decodeIfPresent(String.self, forKey: .content) + ttl = try container.decodeLossyIntIfPresent(forKey: .ttl) + prio = try container.decodeLossyIntIfPresent(forKey: .prio) + weight = try container.decodeLossyIntIfPresent(forKey: .weight) + port = try container.decodeLossyIntIfPresent(forKey: .port) + target = try container.decodeIfPresent(String.self, forKey: .target) + sshAlgorithm = try container.decodeLossyIntIfPresent(forKey: .sshAlgorithm) + sshType = try container.decodeLossyIntIfPresent(forKey: .sshType) + } + + func withDomain(_ domain: String) -> DNSRecord { + DNSRecord( + id: id, + domain: domain, + type: type, + name: name, + content: content, + ttl: ttl, + prio: prio, + weight: weight, + port: port, + target: target, + sshAlgorithm: sshAlgorithm, + sshType: sshType + ) + } +} + +struct APIToken: Codable, Identifiable, Hashable { + var id: String { key } + + let key: String + let comment: String? + let from: [String]? + let allowedDomains: [String]? + let allowedServers: [String]? + let allowedMethods: [String]? + let allowedPrefixes: [String]? + let allowedTypes: [String]? + + enum CodingKeys: String, CodingKey { + case key + case comment + case from + case allowedDomains = "allowed_domains" + case allowedServers = "allowed_servers" + case allowedMethods = "allowed_methods" + case allowedPrefixes = "allowed_prefixes" + case allowedTypes = "allowed_types" + } +} + +enum DNSRecordType: String, CaseIterable, Identifiable, Codable { + case a = "A" + case aaaa = "AAAA" + case aname = "ANAME" + case caa = "CAA" + case cname = "CNAME" + case ds = "DS" + case dynamic = "Dynamic" + case https = "HTTPS" + case mx = "MX" + case naptr = "NAPTR" + case ns = "NS" + case ptr = "PTR" + case srv = "SRV" + case sshfp = "SSHFP" + case svcb = "SVCB" + case tlsa = "TLSA" + case txt = "TXT" + + var id: String { rawValue } + + var usesContent: Bool { + switch self { + case .dynamic, .https, .svcb: + return false + default: + return true + } + } + + var usesTTL: Bool { + usesContent + } + + var usesPriority: Bool { + switch self { + case .https, .mx, .srv, .svcb: + return true + default: + return false + } + } + + var usesWeight: Bool { + self == .srv + } + + var usesPort: Bool { + self == .srv + } + + var usesTarget: Bool { + self == .https || self == .svcb + } + + var usesSSHFields: Bool { + self == .sshfp + } +} + +struct DNSRecordDraft: Equatable { + var type: DNSRecordType = .a + var name = "" + var content = "" + var ttl = "" + var prio = "" + var weight = "" + var port = "" + var target = "" + var sshAlgorithm = "" + var sshType = "" + + init() {} + + init(record: DNSRecord) { + type = DNSRecordType(rawValue: record.type) ?? .a + name = record.name + content = record.content ?? "" + ttl = record.ttl.map(String.init) ?? "" + prio = record.prio.map(String.init) ?? "" + weight = record.weight.map(String.init) ?? "" + port = record.port.map(String.init) ?? "" + target = record.target ?? "" + sshAlgorithm = record.sshAlgorithm.map(String.init) ?? "" + sshType = record.sshType.map(String.init) ?? "" + } + + mutating func resetTypeSpecificFields() { + content = "" + ttl = "" + prio = "" + weight = "" + port = "" + target = "" + sshAlgorithm = "" + sshType = "" + } + + var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var canSubmit: Bool { + !trimmedName.isEmpty + } + + func params(domain: String, id: String? = nil) -> [String: Any] { + var params: [String: Any] = [ + "domain": domain, + "type": type.rawValue, + "name": trimmedName + ] + + if let id { + params["id"] = id + } + + if type.usesContent { + params["content"] = content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + if type.usesTTL, let value = Int(ttl.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["ttl"] = value + } + + if type.usesPriority, let value = Int(prio.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["prio"] = value + } + + if type.usesWeight, let value = Int(weight.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["weight"] = value + } + + if type.usesPort, let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["port"] = value + } + + if type.usesTarget { + params["target"] = target.trimmingCharacters(in: .whitespacesAndNewlines) + } + + if type.usesSSHFields { + if let value = Int(sshAlgorithm.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["ssh_algorithm"] = value + } + if let value = Int(sshType.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["ssh_type"] = value + } + } + + return params + } +} + +struct DomainUpdateRequest { + var autorenew: Bool + var mailforwarding: Bool + var dnssec: Bool + var lock: Bool + var nameservers: [String] + + var params: [String: Any] { + [ + "autorenew": autorenew, + "mailforwarding": mailforwarding, + "dnssec": dnssec, + "lock": lock, + "nameservers": nameservers + ] + } +} + +struct TokenCreateRequest { + var comment: String + var from: [String] + var allowedMethods: [String] + + var params: [String: Any] { + var params: [String: Any] = [:] + + let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedComment.isEmpty { + params["comment"] = trimmedComment + } + + if !from.isEmpty { + params["from"] = from + } + + if !allowedMethods.isEmpty { + params["allowed_methods"] = allowedMethods + } + + return params + } +} + +extension String { + func formattedExpiry() -> String { + let parser = ISO8601DateFormatter() + guard let date = parser.date(from: self) else { return self } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +private extension KeyedDecodingContainer where K == DNSRecord.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 new file mode 100644 index 0000000..9158e20 --- /dev/null +++ b/Rune/API/NjallaClient.swift @@ -0,0 +1,129 @@ +import Foundation + +struct NjallaClient: Sendable, Equatable { + private let token: String + private let endpoint = URL(string: "https://njal.la/api/1/")! + + init(token: String) { + self.token = token + } + + func call<T: Decodable>(_ method: String, params: [String: Any] = [:]) async throws -> T { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Njalla \(token)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = [ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": "1" + ] + + 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 + + let decoder = JSONDecoder() + let envelope = try decoder.decode(RPCResponse<T>.self, from: data) + + if let error = envelope.error { + throw NjallaError.api(message: error.message) + } + + guard let result = envelope.result else { + throw NjallaError.missingResult + } + + return result + } + + func listDomains() async throws -> [Domain] { + let response: DomainListResponse = try await call("list-domains") + return response.domains + } + + func getDomain(named domain: String) async throws -> Domain { + try await call("get-domain", params: ["domain": domain]) + } + + func editDomain(named domain: String, request: DomainUpdateRequest) async throws -> Domain { + var params = request.params + params["domain"] = domain + return try await call("edit-domain", params: params) + } + + 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 + record.domain.isEmpty ? record.withDomain(domain) : record + } + } + + func addRecord(for domain: String, draft: DNSRecordDraft) async throws -> DNSRecord { + try await call("add-record", params: draft.params(domain: domain)) + } + + func editRecord(for domain: String, id: String, draft: DNSRecordDraft) async throws -> DNSRecord { + try await call("edit-record", params: draft.params(domain: domain, id: id)) + } + + func removeRecord(_ record: DNSRecord) async throws { + 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) + } + + func listTokens() async throws -> [APIToken] { + let response: TokenListResponse = try await call("list-tokens") + return response.tokens + } + + func addToken(request: TokenCreateRequest) async throws { + let _: EmptyResult = try await call("add-token", params: request.params) + } + + func removeToken(key: String) async throws { + let _: EmptyResult = try await call("remove-token", params: ["key": key]) + } + + func getBalance() async throws -> WalletBalance { + try await call("get-balance") + } +} + +enum NjallaError: LocalizedError { + case api(message: String) + case missingResult + + var errorDescription: String? { + switch self { + case .api(let message): + return message + case .missingResult: + return "The API response did not include a result." + } + } +} + +private struct RPCResponse<Result: Decodable>: Decodable { + let result: Result? + let error: RPCError? +} + +private struct RPCError: Decodable { + let code: Int + let message: String +} diff --git a/Rune/App/RuneApp.swift b/Rune/App/RuneApp.swift new file mode 100644 index 0000000..8c63dd6 --- /dev/null +++ b/Rune/App/RuneApp.swift @@ -0,0 +1,76 @@ +import SwiftUI + +@main +struct RuneApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} + +private struct RootView: View { + @StateObject private var settingsViewModel = SettingsViewModel() + @StateObject private var domainViewModel = DomainViewModel() + @StateObject private var tokenViewModel = TokenViewModel() + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + DomainListView(viewModel: domainViewModel, client: settingsViewModel.client) + .tabItem { + Label("Domains", systemImage: "globe") + } + .tag(0) + + TokenListView(viewModel: tokenViewModel, client: settingsViewModel.client) { deletedTokenKey in + do { + let didLogout = try settingsViewModel.logoutIfCurrentTokenDeleted(deletedTokenKey) + if didLogout { + selectedTab = 0 + } + } catch { + settingsViewModel.errorMessage = error.localizedDescription + } + } + .tabItem { + Label("Tokens", systemImage: "key.horizontal") + } + .tag(1) + + SettingsView(viewModel: settingsViewModel) { + domainViewModel.reset() + tokenViewModel.reset() + selectedTab = 0 + } + .tabItem { + Label("Settings", systemImage: "gearshape") + } + .tag(2) + } + .task { + await settingsViewModel.bootstrap() + } + .task(id: settingsViewModel.isAuthenticated) { + guard let client = settingsViewModel.client else { + domainViewModel.reset() + tokenViewModel.reset() + selectedTab = 0 + return + } + + await domainViewModel.loadDomains(client: client) + await tokenViewModel.loadTokens(client: client) + } + .fullScreenCover(isPresented: onboardingBinding) { + OnboardingView(viewModel: settingsViewModel) + } + } + + private var onboardingBinding: Binding<Bool> { + Binding( + get: { settingsViewModel.requiresOnboarding }, + set: { _ in } + ) + } +} diff --git a/Rune/Assets.xcassets/AccentColor.colorset/Contents.json b/Rune/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Rune/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rune/Assets.xcassets/AppIcon.appiconset/Contents.json b/Rune/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Rune/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rune/Assets.xcassets/Contents.json b/Rune/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Rune/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rune/Keychain/KeychainManager.swift b/Rune/Keychain/KeychainManager.swift new file mode 100644 index 0000000..bade4f8 --- /dev/null +++ b/Rune/Keychain/KeychainManager.swift @@ -0,0 +1,91 @@ +import Foundation +import Security + +struct KeychainManager { + private let service = "net.cleberg.rune" + private let account = "api-token" + + func saveToken(_ token: String) throws { + let encodedToken = Data(token.utf8) + var query = baseQuery() + query[kSecValueData as String] = encodedToken + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + + let status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { + try updateToken(token) + return + } + + guard status == errSecSuccess else { + throw KeychainError.unhandled(status) + } + } + + func readToken() throws -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess else { + throw KeychainError.unhandled(status) + } + + guard + let data = result as? Data, + let token = String(data: data, encoding: .utf8) + else { + throw KeychainError.invalidData + } + + return token + } + + func deleteToken() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unhandled(status) + } + } + + private func updateToken(_ token: String) throws { + let attributes: [String: Any] = [ + kSecValueData as String: Data(token.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + let status = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary) + guard status == errSecSuccess else { + throw KeychainError.unhandled(status) + } + } + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} + +enum KeychainError: LocalizedError { + case invalidData + case unhandled(OSStatus) + + var errorDescription: String? { + switch self { + case .invalidData: + return "Stored token data is invalid." + case .unhandled(let status): + return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error: \(status)" + } + } +} diff --git a/Rune/ViewModels/DomainViewModel.swift b/Rune/ViewModels/DomainViewModel.swift new file mode 100644 index 0000000..efad5b9 --- /dev/null +++ b/Rune/ViewModels/DomainViewModel.swift @@ -0,0 +1,143 @@ +import Combine +import Foundation + +@MainActor +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 isLoadingDomains = false + @Published private(set) var isLoadingDetail = false + @Published private(set) var isLoadingRecords = false + @Published private(set) var isSaving = false + @Published var errorMessage: String? + + func reset() { + domains = [] + selectedDomain = nil + records = [] + isLoadingDomains = false + isLoadingDetail = false + isLoadingRecords = false + isSaving = false + errorMessage = nil + } + + func loadDomains(client: NjallaClient) async { + isLoadingDomains = true + defer { + isLoadingDomains = false + } + + do { + domains = try await client.listDomains().sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + errorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + errorMessage = error.localizedDescription + } + } + + func loadDomainDetail(named name: String, client: NjallaClient) async { + isLoadingDetail = true + defer { + isLoadingDetail = false + } + + do { + let domain = try await client.getDomain(named: name) + selectedDomain = domain + if let index = domains.firstIndex(where: { $0.name == name }) { + domains[index] = domain + } + errorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + errorMessage = error.localizedDescription + } + } + + func updateDomain(named name: String, request: DomainUpdateRequest, client: NjallaClient) async throws { + isSaving = true + defer { + isSaving = false + } + + 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 + } + errorMessage = nil + } + + func loadRecords(for domain: String, client: NjallaClient) async { + isLoadingRecords = true + defer { + isLoadingRecords = false + } + + do { + records = try await client.listRecords(for: domain).sorted { + ($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id) + } + errorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + errorMessage = error.localizedDescription + } + } + + func addRecord(for domain: String, draft: DNSRecordDraft, client: NjallaClient) async throws { + isSaving = true + defer { + isSaving = false + } + + _ = 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 { + isSaving = true + defer { + isSaving = false + } + + _ = 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 { + isSaving = true + defer { + isSaving = false + } + + try await client.removeRecord(record) + try await reloadRecords(for: record.domain, client: client) + errorMessage = nil + } + + private func reloadRecords(for domain: String, client: NjallaClient) async throws { + records = try await client.listRecords(for: domain).sorted { + ($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id) + } + } +} diff --git a/Rune/ViewModels/SettingsViewModel.swift b/Rune/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..6b773eb --- /dev/null +++ b/Rune/ViewModels/SettingsViewModel.swift @@ -0,0 +1,102 @@ +import Combine +import Foundation + +@MainActor +final class SettingsViewModel: ObservableObject { + @Published private(set) var client: NjallaClient? + @Published private(set) var balance: WalletBalance? + @Published private(set) var isAuthenticated = false + @Published private(set) var isBootstrapped = false + @Published private(set) var isLoadingBalance = false + @Published var errorMessage: String? + + private let keychainManager = KeychainManager() + + var requiresOnboarding: Bool { + isBootstrapped && !isAuthenticated + } + + func bootstrap() async { + guard !isBootstrapped else { return } + + defer { + isBootstrapped = true + } + + do { + guard let token = try keychainManager.readToken(), !token.isEmpty else { + isAuthenticated = false + client = nil + balance = nil + return + } + + let client = NjallaClient(token: token) + self.client = client + isAuthenticated = true + try await refreshBalance() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + errorMessage = error.localizedDescription + client = nil + balance = nil + isAuthenticated = false + } + } + + func login(token: String) async throws { + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + let client = NjallaClient(token: trimmedToken) + let balance = try await client.getBalance() + try keychainManager.saveToken(trimmedToken) + + self.client = client + self.balance = balance + isAuthenticated = true + isBootstrapped = true + errorMessage = nil + } + + func refreshBalance() async throws { + guard let client else { return } + + isLoadingBalance = true + defer { + isLoadingBalance = false + } + + do { + balance = try await client.getBalance() + errorMessage = nil + } catch is CancellationError { + throw CancellationError() + } catch { + if (error as? URLError)?.code == .cancelled { + throw error + } + errorMessage = error.localizedDescription + throw error + } + } + + func logout() throws { + try keychainManager.deleteToken() + client = nil + balance = nil + isAuthenticated = false + errorMessage = nil + } + + func logoutIfCurrentTokenDeleted(_ key: String) throws -> Bool { + guard let storedToken = try keychainManager.readToken(), storedToken == key else { + return false + } + + try logout() + return true + } +} diff --git a/Rune/ViewModels/TokenViewModel.swift b/Rune/ViewModels/TokenViewModel.swift new file mode 100644 index 0000000..40495b4 --- /dev/null +++ b/Rune/ViewModels/TokenViewModel.swift @@ -0,0 +1,91 @@ +import Combine +import Foundation + +@MainActor +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? + + func reset() { + tokens = [] + isLoading = false + isSaving = false + errorMessage = nil + } + + func loadTokens(client: NjallaClient) async { + isLoading = true + defer { + isLoading = false + } + + do { + tokens = try await client.listTokens().sorted { + tokenLabel(for: $0).localizedCaseInsensitiveCompare(tokenLabel(for: $1)) == .orderedAscending + } + errorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + errorMessage = error.localizedDescription + } + } + + func addToken(request: TokenCreateRequest, client: NjallaClient) async -> Bool { + isSaving = true + defer { + isSaving = false + } + + do { + try await client.addToken(request: request) + tokens = try await client.listTokens() + errorMessage = nil + return true + } catch is CancellationError { + return false + } catch { + if (error as? URLError)?.code == .cancelled { + return false + } + errorMessage = error.localizedDescription + return false + } + } + + func removeToken(_ token: APIToken, client: NjallaClient) async -> Bool { + isSaving = true + defer { + isSaving = false + } + + do { + try await client.removeToken(key: token.key) + tokens.removeAll { $0.key == token.key } + errorMessage = nil + return true + } catch is CancellationError { + return false + } catch { + if (error as? URLError)?.code == .cancelled { + return false + } + errorMessage = error.localizedDescription + return false + } + } + + func tokenLabel(for token: APIToken) -> String { + let trimmedComment = token.comment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedComment.isEmpty { + return trimmedComment + } + + return String(token.key.prefix(8)) + } +} diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift new file mode 100644 index 0000000..dbb90a1 --- /dev/null +++ b/Rune/Views/Domains/DomainDetailView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct DomainDetailView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + var body: some View { + Group { + if viewModel.isLoadingDetail && viewModel.selectedDomain?.name != domainName { + ProgressView() + } else if let domain = currentDomain { + List { + Section("Status") { + DetailRow(label: "Name", value: domain.name) + DetailRow(label: "Status", value: textValue(domain.status)) + DetailRow(label: "Expiry", value: domain.expiry?.formattedExpiry() ?? "Not available") + DetailRow(label: "Autorenew", value: boolText(domain.autorenew)) + } + + Section("Settings") { + DetailRow(label: "Mail Forwarding", value: boolText(domain.mailforwarding)) + DetailRow(label: "DNSSEC", value: boolText(domain.dnssec)) + DetailRow(label: "Registrar Lock", value: boolText(domain.lock)) + DetailRow(label: "Nameservers", value: nameserverText(domain.nameservers)) + } + + Section("DNS") { + NavigationLink("Records") { + RecordListView(domainName: domain.name, viewModel: viewModel, client: client) + } + } + } + .listStyle(.insetGrouped) + .toolbar { + NavigationLink("Edit") { + DomainEditView(domain: domain, viewModel: viewModel, client: client) + } + } + } else { + ContentUnavailableView("Domain Unavailable", systemImage: "globe", description: Text("The domain details could not be loaded.")) + } + } + .navigationTitle(domainName) + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadDomainDetail(named: domainName, client: client) + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var currentDomain: Domain? { + if viewModel.selectedDomain?.name == domainName { + return viewModel.selectedDomain + } + + return viewModel.domains.first(where: { $0.name == domainName }) + } + + private func boolText(_ value: Bool?) -> String { + guard let value else { return "Not available" } + return value ? "On" : "Off" + } + + private func nameserverText(_ nameservers: [String]?) -> String { + guard let nameservers else { + return "Not available" + } + + guard !nameservers.isEmpty else { + return "Default" + } + + return nameservers.joined(separator: ", ") + } + + private func textValue(_ value: String?) -> String { + guard let value, !value.isEmpty else { + return "Not available" + } + + return value + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} + +private struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } +} diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift new file mode 100644 index 0000000..3f197e3 --- /dev/null +++ b/Rune/Views/Domains/DomainEditView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct DomainEditView: View { + let domain: Domain + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var autorenew: Bool + @State private var mailforwarding: Bool + @State private var dnssec: Bool + @State private var lock: Bool + @State private var nameserversText: String + @State private var localErrorMessage: String? + + init(domain: Domain, viewModel: DomainViewModel, client: NjallaClient) { + self.domain = domain + self.viewModel = viewModel + self.client = client + _autorenew = State(initialValue: domain.autorenew ?? false) + _mailforwarding = State(initialValue: domain.mailforwarding ?? false) + _dnssec = State(initialValue: domain.dnssec ?? false) + _lock = State(initialValue: domain.lock ?? false) + _nameserversText = State(initialValue: (domain.nameservers ?? []).joined(separator: "\n")) + } + + var body: some View { + Form { + Section("Settings") { + Toggle("Autorenew", isOn: $autorenew) + Toggle("Mail Forwarding", isOn: $mailforwarding) + Toggle("DNSSEC", isOn: $dnssec) + Toggle("Registrar Lock", isOn: $lock) + } + + Section { + TextEditor(text: $nameserversText) + .frame(minHeight: 120) + } header: { + Text("Nameservers") + } footer: { + Text("Enter one nameserver per line. Leave blank to use Njalla defaults.") + } + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving) + } + } + .navigationTitle("Edit Domain") + .navigationBarTitleDisplayMode(.inline) + .alert("API Error", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + } + + private func save() async { + let request = DomainUpdateRequest( + autorenew: autorenew, + mailforwarding: mailforwarding, + dnssec: dnssec, + lock: lock, + nameservers: nameserversText + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ) + + do { + try await viewModel.updateDomain(named: domain.name, request: request, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } +} diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift new file mode 100644 index 0000000..d728bd7 --- /dev/null +++ b/Rune/Views/Domains/DomainListView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct DomainListView: View { + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient? + + var body: some View { + NavigationStack { + Group { + if let client { + content(client: client) + } else { + ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load domains.")) + } + } + .navigationTitle("Domains") + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + @ViewBuilder + private func content(client: NjallaClient) -> some View { + if viewModel.isLoadingDomains && viewModel.domains.isEmpty { + ProgressView() + } else if viewModel.domains.isEmpty { + ContentUnavailableView("No Domains", systemImage: "globe", description: Text("No domains found on this account.")) + } else { + List(viewModel.domains) { domain in + NavigationLink { + DomainDetailView(domainName: domain.name, viewModel: viewModel, client: client) + } label: { + DomainRow(domain: domain) + } + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadDomains(client: client) + } + } + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} + +private struct DomainRow: View { + let domain: Domain + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(domain.name) + .font(.headline) + + HStack { + if let status = domain.status { + Text(status) + } + + if let expiry = domain.expiry { + Text("Expiry: \(expiry.formattedExpiry())") + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + + if let autorenew = domain.autorenew { + Text(autorenew ? "Autorenew On" : "Autorenew Off") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} diff --git a/Rune/Views/Domains/RecordAddView.swift b/Rune/Views/Domains/RecordAddView.swift new file mode 100644 index 0000000..8dfa81d --- /dev/null +++ b/Rune/Views/Domains/RecordAddView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct RecordAddView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var draft = DNSRecordDraft() + @State private var localErrorMessage: String? + var body: some View { + Form { + DNSRecordFormSections(draft: $draft) + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving || !draft.canSubmit) + } + } + .navigationTitle("Add Record") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + } + } + .onChange(of: draft.type) { oldValue, newValue in + guard oldValue != newValue else { return } + draft.resetTypeSpecificFields() + } + .alert("API Error", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + } + + private func save() async { + do { + try await viewModel.addRecord(for: domainName, draft: draft, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } +} + +struct DNSRecordFormSections: View { + @Binding var draft: DNSRecordDraft + + var body: some View { + Section("Record") { + Picker("Type", selection: $draft.type) { + ForEach(DNSRecordType.allCases) { type in + Text(type.rawValue).tag(type) + } + } + + TextField("Name", text: $draft.name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + if draft.type.usesContent { + Section("Content") { + TextField("Content", text: $draft.content, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + if draft.type.usesTTL { + Section("TTL") { + TextField("TTL", text: $draft.ttl) + .keyboardType(.numberPad) + } + } + + if draft.type.usesPriority { + Section("Priority") { + TextField("Priority", text: $draft.prio) + .keyboardType(.numberPad) + } + } + + if draft.type.usesWeight { + Section("Weight") { + TextField("Weight", text: $draft.weight) + .keyboardType(.numberPad) + } + } + + if draft.type.usesPort { + Section("Port") { + TextField("Port", text: $draft.port) + .keyboardType(.numberPad) + } + } + + if draft.type.usesTarget { + Section("Target") { + TextField("Target", text: $draft.target) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + if draft.type.usesSSHFields { + Section { + TextField("SSH Algorithm", text: $draft.sshAlgorithm) + .keyboardType(.numberPad) + TextField("SSH Type", text: $draft.sshType) + .keyboardType(.numberPad) + } header: { + Text("SSHFP") + } footer: { + Text("Algorithm values: 1-5. Type values: 1-2.") + } + } + } +} diff --git a/Rune/Views/Domains/RecordEditView.swift b/Rune/Views/Domains/RecordEditView.swift new file mode 100644 index 0000000..e0c0fde --- /dev/null +++ b/Rune/Views/Domains/RecordEditView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct RecordEditView: View { + let domainName: String + let record: DNSRecord + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var draft: DNSRecordDraft + @State private var showingDeleteConfirmation = false + @State private var localErrorMessage: String? + + init(domainName: String, record: DNSRecord, viewModel: DomainViewModel, client: NjallaClient) { + self.domainName = domainName + self.record = record + self.viewModel = viewModel + self.client = client + _draft = State(initialValue: DNSRecordDraft(record: record)) + } + + var body: some View { + Form { + DNSRecordFormSections(draft: $draft) + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving || !draft.canSubmit) + } + + Section { + Button("Delete Record", role: .destructive) { + showingDeleteConfirmation = true + } + .foregroundStyle(.red) + } + } + .navigationTitle(record.name) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: draft.type) { oldValue, newValue in + guard oldValue != newValue else { return } + draft.resetTypeSpecificFields() + } + .confirmationDialog( + "Delete \(record.type) record \(record.name)?", + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Record", role: .destructive) { + Task { + await deleteRecord() + } + } + } + .alert("API Error", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + } + + private func save() async { + do { + try await viewModel.editRecord(for: domainName, recordID: record.id, draft: draft, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private func deleteRecord() async { + do { + try await viewModel.removeRecord(record, client: client) + dismiss() + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + localErrorMessage = error.localizedDescription + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } +} diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift new file mode 100644 index 0000000..67a4140 --- /dev/null +++ b/Rune/Views/Domains/RecordListView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct RecordListView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @State private var showingAddRecord = false + + var body: some View { + Group { + if viewModel.isLoadingRecords && viewModel.records.isEmpty { + ProgressView() + } else if viewModel.records.isEmpty { + ContentUnavailableView("No Records", systemImage: "list.bullet", description: Text("No DNS records for this domain.")) + } else { + List(viewModel.records) { record in + NavigationLink { + RecordEditView(domainName: domainName, record: record, viewModel: viewModel, client: client) + } label: { + RecordRow(record: record) + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("DNS Records") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button { + showingAddRecord = true + } label: { + Label("Add Record", systemImage: "plus") + } + } + .sheet(isPresented: $showingAddRecord) { + NavigationStack { + RecordAddView(domainName: domainName, viewModel: viewModel, client: client) + } + } + .task { + await viewModel.loadRecords(for: domainName, client: client) + } + .refreshable { + await viewModel.loadRecords(for: domainName, client: client) + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} + +private struct RecordRow: View { + let record: DNSRecord + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("\(record.type) \(record.name)") + .font(.headline) + if let detail = [record.content, record.target].compactMap({ $0 }).first, !detail.isEmpty { + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} diff --git a/Rune/Views/Onboarding/OnboardingView.swift b/Rune/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..3fba6c9 --- /dev/null +++ b/Rune/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct OnboardingView: View { + @ObservedObject var viewModel: SettingsViewModel + + @State private var token = "" + @State private var isSubmitting = false + @State private var localErrorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Enter Njalla API token", text: $token) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Validate and Save") { + Task { + await submit() + } + } + .disabled(isSubmitting || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } header: { + Text("API Token") + } footer: { + Text("Rune validates the token with `get-balance` before saving it to Keychain.") + } + + if let localErrorMessage { + Section { + Text(localErrorMessage) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Welcome") + .overlay { + if isSubmitting { + ProgressView() + .controlSize(.large) + } + } + } + } + + private func submit() async { + isSubmitting = true + defer { + isSubmitting = false + } + + do { + try await viewModel.login(token: token) + localErrorMessage = nil + } catch { + localErrorMessage = error.localizedDescription + } + } +} diff --git a/Rune/Views/Settings/SettingsView.swift b/Rune/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..4d07887 --- /dev/null +++ b/Rune/Views/Settings/SettingsView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var viewModel: SettingsViewModel + let onLogout: () -> Void + @State private var showingLogoutConfirmation = false + + var body: some View { + NavigationStack { + List { + Section("Wallet") { + if let balance = viewModel.balance { + HStack { + Text("Balance") + Spacer() + Text("€\(balance.balance)") + .foregroundStyle(.secondary) + } + } else if viewModel.isLoadingBalance { + ProgressView() + } else { + Text("Wallet balance unavailable.") + .foregroundStyle(.secondary) + } + + Button("Refresh Balance") { + Task { + do { + try await viewModel.refreshBalance() + } catch { + viewModel.errorMessage = error.localizedDescription + } + } + } + .disabled(viewModel.client == nil || viewModel.isLoadingBalance) + } + + Section("Account") { + Button("Logout", role: .destructive) { + showingLogoutConfirmation = true + } + .foregroundStyle(.red) + } + + if let errorMessage = viewModel.errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Settings") + } + .confirmationDialog( + "Log out of Rune?", + isPresented: $showingLogoutConfirmation, + titleVisibility: .visible + ) { + Button("Log Out", role: .destructive) { + do { + try viewModel.logout() + onLogout() + } catch { + viewModel.errorMessage = error.localizedDescription + } + } + + Button("Cancel", role: .cancel) {} + } message: { + Text("Your API token will be removed from this device.") + } + } +} diff --git a/Rune/Views/Tokens/TokenAddView.swift b/Rune/Views/Tokens/TokenAddView.swift new file mode 100644 index 0000000..6b71783 --- /dev/null +++ b/Rune/Views/Tokens/TokenAddView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct TokenAddView: View { + @ObservedObject var viewModel: TokenViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + + @State private var comment = "" + @State private var fromText = "" + @State private var allowedMethodsText = "" + @State private var ipValidationMessage: String? + @State private var methodValidationMessage: String? + + var body: some View { + Form { + Section("Token") { + TextField("Comment", text: $comment) + } + + Section { + TextEditor(text: $fromText) + .frame(minHeight: 100) + if let ipValidationMessage { + Text(ipValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("IP Restrictions") + } footer: { + Text("Enter one IPv4, IPv6, or CIDR range per line.") + } + + Section { + TextEditor(text: $allowedMethodsText) + .frame(minHeight: 100) + if let methodValidationMessage { + Text(methodValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("Allowed Methods") + } footer: { + Text("Enter one API method per line, for example `list-domains`.") + } + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving) + } + } + .navigationTitle("Add Token") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + } + } + .onChange(of: fromText) { _, _ in + ipValidationMessage = nil + } + .onChange(of: allowedMethodsText) { _, _ in + methodValidationMessage = nil + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private func save() async { + guard validateInput() else { + return + } + + let request = TokenCreateRequest( + comment: comment, + from: lines(fromText), + allowedMethods: lines(allowedMethodsText) + ) + + if await viewModel.addToken(request: request, client: client) { + dismiss() + } + } + + private func lines(_ value: String) -> [String] { + value + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private func validateInput() -> Bool { + let ipEntries = lines(fromText) + let methodEntries = lines(allowedMethodsText) + + ipValidationMessage = ipEntries.allSatisfy(isValidIPOrCIDR(_:)) + ? nil + : "One or more entries are not valid IP addresses or CIDR ranges." + + methodValidationMessage = methodEntries.allSatisfy(isValidMethodName(_:)) + ? nil + : "One or more method names appear invalid. Use format: list-domains" + + return ipValidationMessage == nil && methodValidationMessage == nil + } + + private func isValidMethodName(_ value: String) -> Bool { + value.range(of: "^[a-z-]+$", options: .regularExpression) != nil + } + + private func isValidIPOrCIDR(_ value: String) -> Bool { + let pattern = #"^((\d{1,3}\.){3}\d{1,3})(/(3[0-2]|[12]?\d))?$|^([0-9A-Fa-f:]+)(/\d{1,3})?$"# + return value.range(of: pattern, options: .regularExpression) != nil + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} diff --git a/Rune/Views/Tokens/TokenListView.swift b/Rune/Views/Tokens/TokenListView.swift new file mode 100644 index 0000000..51bae49 --- /dev/null +++ b/Rune/Views/Tokens/TokenListView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct TokenListView: View { + @ObservedObject var viewModel: TokenViewModel + let client: NjallaClient? + let onTokenRemoved: (String) -> Void + + @State private var showingAddToken = false + @State private var tokenPendingDeletion: APIToken? + + var body: some View { + NavigationStack { + Group { + if let client { + content(client: client) + } else { + ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load account tokens.")) + } + } + .navigationTitle("Tokens") + .toolbar { + if client != nil { + Button { + showingAddToken = true + } label: { + Label("Add Token", systemImage: "plus") + } + } + } + } + .sheet(isPresented: $showingAddToken) { + if let client { + NavigationStack { + TokenAddView(viewModel: viewModel, client: client) + } + } + } + .confirmationDialog( + deletionTitle, + isPresented: deleteBinding, + titleVisibility: .visible + ) { + Button("Delete Token", role: .destructive) { + guard let tokenPendingDeletion, let client else { return } + Task { + let removed = await viewModel.removeToken(tokenPendingDeletion, client: client) + if removed { + onTokenRemoved(tokenPendingDeletion.key) + } + self.tokenPendingDeletion = nil + } + } + } + .alert("API Error", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + @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: { + TokenRow(token: token, label: viewModel.tokenLabel(for: token)) + } + .buttonStyle(.plain) + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadTokens(client: client) + } + } + } + + private var deletionTitle: String { + guard let tokenPendingDeletion else { + return "" + } + + return "Delete token \(viewModel.tokenLabel(for: tokenPendingDeletion))?" + } + + private var deleteBinding: Binding<Bool> { + Binding( + get: { tokenPendingDeletion != nil }, + set: { newValue in + if !newValue { + tokenPendingDeletion = nil + } + } + ) + } + + private var errorBinding: Binding<Bool> { + Binding( + get: { viewModel.errorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.errorMessage = nil + } + } + ) + } +} + +private struct TokenRow: View { + let token: APIToken + let label: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.headline) + + Text(methodsText) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let from = token.from, !from.isEmpty { + Text("From: \(from.joined(separator: ", "))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private var methodsText: String { + guard let methods = token.allowedMethods, !methods.isEmpty else { + return "Methods: Unrestricted" + } + + return "Methods: \(methods.joined(separator: ", "))" + } +} |
