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}