From cf587aa0573ac4f34e1effe70108a9eca82093ac Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 24 Mar 2026 21:48:23 -0500 Subject: v1.0 --- Rune/Views/Tokens/TokenAddView.swift | 137 ++++++++++++++++++++++++++++++++ Rune/Views/Tokens/TokenListView.swift | 143 ++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 Rune/Views/Tokens/TokenAddView.swift create mode 100644 Rune/Views/Tokens/TokenListView.swift (limited to 'Rune/Views/Tokens') 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 { + 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 { + Binding( + get: { tokenPendingDeletion != nil }, + set: { newValue in + if !newValue { + tokenPendingDeletion = nil + } + } + ) + } + + private var errorBinding: Binding { + 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: ", "))" + } +} -- cgit v1.2.3