diff options
| author | Christian Cleberg <[email protected]> | 2026-04-11 11:48:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-11 11:48:40 -0500 |
| commit | fcf864a15b70e4ecb5bd789b1db3116221c34394 (patch) | |
| tree | f15315324b9f95286b6bf7392dd0ccaee210c2de /Rune/Views/Tokens | |
| parent | cf587aa0573ac4f34e1effe70108a9eca82093ac (diff) | |
| download | rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.gz rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.bz2 rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.zip | |
add FUNDING.yml
Diffstat (limited to 'Rune/Views/Tokens')
| -rw-r--r-- | Rune/Views/Tokens/TokenAddView.swift | 98 | ||||
| -rw-r--r-- | Rune/Views/Tokens/TokenListView.swift | 92 |
2 files changed, 160 insertions, 30 deletions
diff --git a/Rune/Views/Tokens/TokenAddView.swift b/Rune/Views/Tokens/TokenAddView.swift index 6b71783..184398f 100644 --- a/Rune/Views/Tokens/TokenAddView.swift +++ b/Rune/Views/Tokens/TokenAddView.swift @@ -9,8 +9,12 @@ struct TokenAddView: View { @State private var comment = "" @State private var fromText = "" @State private var allowedMethodsText = "" + @State private var allowedDomainsText = "" + @State private var unrestrictedConfirmed = false @State private var ipValidationMessage: String? @State private var methodValidationMessage: String? + @State private var domainValidationMessage: String? + @State private var restrictionValidationMessage: String? var body: some View { Form { @@ -47,6 +51,36 @@ struct TokenAddView: View { } Section { + TextEditor(text: $allowedDomainsText) + .frame(minHeight: 100) + if let domainValidationMessage { + Text(domainValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("Allowed Domains") + } footer: { + Text("Optional. Enter one domain per line to limit the token to specific domains.") + } + + Section("Restrictions") { + Text("At least one restriction is recommended. Tokens without restrictions can access any allowed API method from any origin.") + .font(.subheadline) + .foregroundStyle(.secondary) + + if lines(fromText).isEmpty && lines(allowedMethodsText).isEmpty && lines(allowedDomainsText).isEmpty { + Toggle("I understand this token will be unrestricted", isOn: $unrestrictedConfirmed) + } + + if let restrictionValidationMessage { + Text(restrictionValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + + Section { Button("Save") { Task { await save() @@ -57,27 +91,48 @@ struct TokenAddView: View { } .navigationTitle("Add Token") .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", role: .cancel) { dismiss() } + .disabled(viewModel.isSaving) } } + .interactiveDismissDisabled(viewModel.isSaving) .onChange(of: fromText) { _, _ in ipValidationMessage = nil + restrictionValidationMessage = nil + unrestrictedConfirmed = false } .onChange(of: allowedMethodsText) { _, _ in methodValidationMessage = nil + restrictionValidationMessage = nil + unrestrictedConfirmed = false } - .alert("API Error", isPresented: errorBinding) { + .onChange(of: allowedDomainsText) { _, _ in + domainValidationMessage = nil + restrictionValidationMessage = nil + unrestrictedConfirmed = false + } + .alert("Request Failed", isPresented: errorBinding) { Button("OK", role: .cancel) {} } message: { - Text(viewModel.errorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } private func save() async { + guard !viewModel.isSaving else { + return + } + guard validateInput() else { return } @@ -85,10 +140,12 @@ struct TokenAddView: View { let request = TokenCreateRequest( comment: comment, from: lines(fromText), - allowedMethods: lines(allowedMethodsText) + allowedMethods: lines(allowedMethodsText), + allowedDomains: lines(allowedDomainsText) ) if await viewModel.addToken(request: request, client: client) { + resetForm() dismiss() } } @@ -103,6 +160,7 @@ struct TokenAddView: View { private func validateInput() -> Bool { let ipEntries = lines(fromText) let methodEntries = lines(allowedMethodsText) + let domainEntries = lines(allowedDomainsText) ipValidationMessage = ipEntries.allSatisfy(isValidIPOrCIDR(_:)) ? nil @@ -112,7 +170,19 @@ struct TokenAddView: View { ? nil : "One or more method names appear invalid. Use format: list-domains" - return ipValidationMessage == nil && methodValidationMessage == nil + domainValidationMessage = domainEntries.allSatisfy(isValidDomainName(_:)) + ? nil + : "One or more domain entries appear invalid." + + let hasRestrictions = !ipEntries.isEmpty || !methodEntries.isEmpty || !domainEntries.isEmpty + restrictionValidationMessage = hasRestrictions || unrestrictedConfirmed + ? nil + : "Add at least one restriction or confirm unrestricted token creation." + + return ipValidationMessage == nil && + methodValidationMessage == nil && + domainValidationMessage == nil && + restrictionValidationMessage == nil } private func isValidMethodName(_ value: String) -> Bool { @@ -124,12 +194,28 @@ struct TokenAddView: View { return value.range(of: pattern, options: .regularExpression) != nil } + private func isValidDomainName(_ value: String) -> Bool { + 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 + } + + private func resetForm() { + comment = "" + fromText = "" + allowedMethodsText = "" + allowedDomainsText = "" + unrestrictedConfirmed = false + ipValidationMessage = nil + methodValidationMessage = nil + domainValidationMessage = nil + restrictionValidationMessage = nil + } + private var errorBinding: Binding<Bool> { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - viewModel.errorMessage = nil + viewModel.dismissMutationError() } } ) diff --git a/Rune/Views/Tokens/TokenListView.swift b/Rune/Views/Tokens/TokenListView.swift index 51bae49..bbe50d9 100644 --- a/Rune/Views/Tokens/TokenListView.swift +++ b/Rune/Views/Tokens/TokenListView.swift @@ -28,21 +28,18 @@ struct TokenListView: View { } } } - .sheet(isPresented: $showingAddToken) { + .fullScreenCover(isPresented: $showingAddToken) { if let client { NavigationStack { TokenAddView(viewModel: viewModel, client: client) } } } - .confirmationDialog( - deletionTitle, - isPresented: deleteBinding, - titleVisibility: .visible - ) { + .alert(deletionTitle, isPresented: deleteBinding) { Button("Delete Token", role: .destructive) { guard let tokenPendingDeletion, let client else { return } Task { + guard !viewModel.isSaving else { return } let removed = await viewModel.removeToken(tokenPendingDeletion, client: client) if removed { onTokenRemoved(tokenPendingDeletion.key) @@ -50,32 +47,73 @@ struct TokenListView: View { self.tokenPendingDeletion = nil } } + + Button("Cancel", role: .cancel) { + tokenPendingDeletion = nil + } + } message: { + Text("This action cannot be undone.") } - .alert("API Error", isPresented: errorBinding) { + .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { - Text(viewModel.errorMessage ?? "") + Text(viewModel.mutationErrorMessage ?? "") } } @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: { + List { + if let errorMessage = viewModel.listErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Tokens") { + Task { + await viewModel.loadTokens(client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoading && viewModel.tokens.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Tokens") + Spacer() + } + } + } else if viewModel.tokens.isEmpty { + Section { + ContentUnavailableView( + "No Tokens", + systemImage: "key.horizontal", + description: Text("No API tokens found. Create a restricted token for specific access.") + ) + } + } else { + ForEach(viewModel.tokens) { token in TokenRow(token: token, label: viewModel.tokenLabel(for: token)) + .contentShape(Rectangle()) + .contextMenu { + Button("Delete Token", role: .destructive) { + tokenPendingDeletion = token + } + } + .disabled(viewModel.isSaving) + .opacity(viewModel.isSaving ? 0.6 : 1) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } - .buttonStyle(.plain) } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.loadTokens(client: client) + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadTokens(client: client) + } + .overlay(alignment: .top) { + if viewModel.isLoading && !viewModel.tokens.isEmpty { + ProgressView() + .padding(.top, 8) } } } @@ -99,12 +137,12 @@ struct TokenListView: View { ) } - private var errorBinding: Binding<Bool> { + private var mutationErrorBinding: Binding<Bool> { Binding( - get: { viewModel.errorMessage != nil }, + get: { viewModel.mutationErrorMessage != nil }, set: { newValue in if !newValue { - viewModel.errorMessage = nil + viewModel.dismissMutationError() } } ) @@ -129,6 +167,12 @@ private struct TokenRow: View { .font(.subheadline) .foregroundStyle(.secondary) } + + if let domains = token.allowedDomains, !domains.isEmpty { + Text("Domains: \(domains.joined(separator: ", "))") + .font(.subheadline) + .foregroundStyle(.secondary) + } } .padding(.vertical, 4) } |
