krz/rune

an ios client for njalla

clone: git clone https://gitbay.org/krz/rune.git

main: Rune/Views/Tokens/TokenAddView.swift · raw

  1import SwiftUI
  2
  3struct TokenAddView: View {
  4    @ObservedObject var viewModel: TokenViewModel
  5    let client: NjallaClient
  6
  7    @Environment(\.dismiss) private var dismiss
  8
  9    @State private var comment = ""
 10    @State private var fromText = ""
 11    @State private var allowedMethodsText = ""
 12    @State private var allowedDomainsText = ""
 13    @State private var unrestrictedConfirmed = false
 14    @State private var ipValidationMessage: String?
 15    @State private var methodValidationMessage: String?
 16    @State private var domainValidationMessage: String?
 17    @State private var restrictionValidationMessage: String?
 18
 19    var body: some View {
 20        Form {
 21            Section("Token") {
 22                TextField("Comment", text: $comment)
 23            }
 24
 25            Section {
 26                TextEditor(text: $fromText)
 27                    .frame(minHeight: 100)
 28                if let ipValidationMessage {
 29                    Text(ipValidationMessage)
 30                        .font(.caption)
 31                        .foregroundStyle(.red)
 32                }
 33            } header: {
 34                Text("IP Restrictions")
 35            } footer: {
 36                Text("Enter one IPv4, IPv6, or CIDR range per line.")
 37            }
 38
 39            Section {
 40                TextEditor(text: $allowedMethodsText)
 41                    .frame(minHeight: 100)
 42                if let methodValidationMessage {
 43                    Text(methodValidationMessage)
 44                        .font(.caption)
 45                        .foregroundStyle(.red)
 46                }
 47            } header: {
 48                Text("Allowed Methods")
 49            } footer: {
 50                Text("Enter one API method per line, for example `list-domains`.")
 51            }
 52
 53            Section {
 54                TextEditor(text: $allowedDomainsText)
 55                    .frame(minHeight: 100)
 56                if let domainValidationMessage {
 57                    Text(domainValidationMessage)
 58                        .font(.caption)
 59                        .foregroundStyle(.red)
 60                }
 61            } header: {
 62                Text("Allowed Domains")
 63            } footer: {
 64                Text("Optional. Enter one domain per line to limit the token to specific domains.")
 65            }
 66
 67            Section("Restrictions") {
 68                Text("At least one restriction is recommended. Tokens without restrictions can access any allowed API method from any origin.")
 69                    .font(.subheadline)
 70                    .foregroundStyle(.secondary)
 71
 72                if lines(fromText).isEmpty && lines(allowedMethodsText).isEmpty && lines(allowedDomainsText).isEmpty {
 73                    Toggle("I understand this token will be unrestricted", isOn: $unrestrictedConfirmed)
 74                }
 75
 76                if let restrictionValidationMessage {
 77                    Text(restrictionValidationMessage)
 78                        .font(.caption)
 79                        .foregroundStyle(.red)
 80                }
 81            }
 82
 83            Section {
 84                Button("Save") {
 85                    Task {
 86                        await save()
 87                    }
 88                }
 89                .disabled(viewModel.isSaving)
 90            }
 91        }
 92        .navigationTitle("Add Token")
 93        .navigationBarTitleDisplayMode(.inline)
 94        .overlay {
 95            if viewModel.isSaving {
 96                ProgressView()
 97                    .controlSize(.large)
 98            }
 99        }
100        .toolbar {
101            ToolbarItem(placement: .cancellationAction) {
102                Button("Cancel", role: .cancel) {
103                    dismiss()
104                }
105                .disabled(viewModel.isSaving)
106            }
107        }
108        .interactiveDismissDisabled(viewModel.isSaving)
109        .onChange(of: fromText) { _, _ in
110            ipValidationMessage = nil
111            restrictionValidationMessage = nil
112            unrestrictedConfirmed = false
113        }
114        .onChange(of: allowedMethodsText) { _, _ in
115            methodValidationMessage = nil
116            restrictionValidationMessage = nil
117            unrestrictedConfirmed = false
118        }
119        .onChange(of: allowedDomainsText) { _, _ in
120            domainValidationMessage = nil
121            restrictionValidationMessage = nil
122            unrestrictedConfirmed = false
123        }
124        .alert("Request Failed", isPresented: errorBinding) {
125            Button("OK", role: .cancel) {}
126        } message: {
127            Text(viewModel.mutationErrorMessage ?? "")
128        }
129    }
130
131    private func save() async {
132        guard !viewModel.isSaving else {
133            return
134        }
135
136        guard validateInput() else {
137            return
138        }
139
140        let request = TokenCreateRequest(
141            comment: comment,
142            from: lines(fromText),
143            allowedMethods: lines(allowedMethodsText),
144            allowedDomains: lines(allowedDomainsText)
145        )
146
147        if await viewModel.addToken(request: request, client: client) {
148            resetForm()
149            dismiss()
150        }
151    }
152
153    private func lines(_ value: String) -> [String] {
154        value
155            .split(whereSeparator: \.isNewline)
156            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
157            .filter { !$0.isEmpty }
158    }
159
160    private func validateInput() -> Bool {
161        let ipEntries = lines(fromText)
162        let methodEntries = lines(allowedMethodsText)
163        let domainEntries = lines(allowedDomainsText)
164
165        ipValidationMessage = ipEntries.allSatisfy(isValidIPOrCIDR(_:))
166            ? nil
167            : "One or more entries are not valid IP addresses or CIDR ranges."
168
169        methodValidationMessage = methodEntries.allSatisfy(isValidMethodName(_:))
170            ? nil
171            : "One or more method names appear invalid. Use format: list-domains"
172
173        domainValidationMessage = domainEntries.allSatisfy(isValidDomainName(_:))
174            ? nil
175            : "One or more domain entries appear invalid."
176
177        let hasRestrictions = !ipEntries.isEmpty || !methodEntries.isEmpty || !domainEntries.isEmpty
178        restrictionValidationMessage = hasRestrictions || unrestrictedConfirmed
179            ? nil
180            : "Add at least one restriction or confirm unrestricted token creation."
181
182        return ipValidationMessage == nil &&
183            methodValidationMessage == nil &&
184            domainValidationMessage == nil &&
185            restrictionValidationMessage == nil
186    }
187
188    private func isValidMethodName(_ value: String) -> Bool {
189        value.range(of: "^[a-z-]+$", options: .regularExpression) != nil
190    }
191
192    private func isValidIPOrCIDR(_ value: String) -> Bool {
193        let pattern = #"^((\d{1,3}\.){3}\d{1,3})(/(3[0-2]|[12]?\d))?$|^([0-9A-Fa-f:]+)(/\d{1,3})?$"#
194        return value.range(of: pattern, options: .regularExpression) != nil
195    }
196
197    private func isValidDomainName(_ value: String) -> Bool {
198        value.range(of: #"^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$"#, options: .regularExpression) != nil
199    }
200
201    private func resetForm() {
202        comment = ""
203        fromText = ""
204        allowedMethodsText = ""
205        allowedDomainsText = ""
206        unrestrictedConfirmed = false
207        ipValidationMessage = nil
208        methodValidationMessage = nil
209        domainValidationMessage = nil
210        restrictionValidationMessage = nil
211    }
212
213    private var errorBinding: Binding<Bool> {
214        Binding(
215            get: { viewModel.mutationErrorMessage != nil },
216            set: { newValue in
217                if !newValue {
218                    viewModel.dismissMutationError()
219                }
220            }
221        )
222    }
223}