summaryrefslogtreecommitdiff
path: root/Rune/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-24 21:48:23 -0500
committerChristian Cleberg <[email protected]>2026-03-24 21:48:23 -0500
commitcf587aa0573ac4f34e1effe70108a9eca82093ac (patch)
tree833e6f4961522e35b08af01b5f012983f8e34678 /Rune/Views
downloadrune-1.0.0.tar.gz
rune-1.0.0.tar.bz2
rune-1.0.0.zip
v1.0v1.0.0
Diffstat (limited to 'Rune/Views')
-rw-r--r--Rune/Views/Domains/DomainDetailView.swift114
-rw-r--r--Rune/Views/Domains/DomainEditView.swift99
-rw-r--r--Rune/Views/Domains/DomainListView.swift86
-rw-r--r--Rune/Views/Domains/RecordAddView.swift144
-rw-r--r--Rune/Views/Domains/RecordEditView.swift105
-rw-r--r--Rune/Views/Domains/RecordListView.swift81
-rw-r--r--Rune/Views/Onboarding/OnboardingView.swift60
-rw-r--r--Rune/Views/Settings/SettingsView.swift73
-rw-r--r--Rune/Views/Tokens/TokenAddView.swift137
-rw-r--r--Rune/Views/Tokens/TokenListView.swift143
10 files changed, 1042 insertions, 0 deletions
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: ", "))"
+ }
+}