summaryrefslogtreecommitdiff
path: root/Rune/Views/Tokens
diff options
context:
space:
mode:
Diffstat (limited to 'Rune/Views/Tokens')
-rw-r--r--Rune/Views/Tokens/TokenAddView.swift137
-rw-r--r--Rune/Views/Tokens/TokenListView.swift143
2 files changed, 280 insertions, 0 deletions
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: ", "))"
+ }
+}