krz/rune

an ios client for njalla

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

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

  1import SwiftUI
  2
  3struct TokenListView: View {
  4    @ObservedObject var viewModel: TokenViewModel
  5    let client: NjallaClient?
  6    let onTokenRemoved: (String) -> Void
  7
  8    @State private var showingAddToken = false
  9    @State private var tokenPendingDeletion: APIToken?
 10
 11    var body: some View {
 12        NavigationStack {
 13            Group {
 14                if let client {
 15                    content(client: client)
 16                } else {
 17                    ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load account tokens."))
 18                }
 19            }
 20            .navigationTitle("Tokens")
 21            .toolbar {
 22                if client != nil {
 23                    Button {
 24                        showingAddToken = true
 25                    } label: {
 26                        Label("Add Token", systemImage: "plus")
 27                    }
 28                }
 29            }
 30        }
 31        .fullScreenCover(isPresented: $showingAddToken) {
 32            if let client {
 33                NavigationStack {
 34                    TokenAddView(viewModel: viewModel, client: client)
 35                }
 36            }
 37        }
 38        .alert(deletionTitle, isPresented: deleteBinding) {
 39            Button("Delete Token", role: .destructive) {
 40                guard let tokenPendingDeletion, let client else { return }
 41                Task {
 42                    guard !viewModel.isSaving else { return }
 43                    let removed = await viewModel.removeToken(tokenPendingDeletion, client: client)
 44                    if removed {
 45                        onTokenRemoved(tokenPendingDeletion.key)
 46                    }
 47                    self.tokenPendingDeletion = nil
 48                }
 49            }
 50
 51            Button("Cancel", role: .cancel) {
 52                tokenPendingDeletion = nil
 53            }
 54        } message: {
 55            Text("This action cannot be undone.")
 56        }
 57        .alert("Request Failed", isPresented: mutationErrorBinding) {
 58            Button("OK", role: .cancel) {}
 59        } message: {
 60            Text(viewModel.mutationErrorMessage ?? "")
 61        }
 62    }
 63
 64    @ViewBuilder
 65    private func content(client: NjallaClient) -> some View {
 66        List {
 67            if let errorMessage = viewModel.listErrorMessage {
 68                Section {
 69                    InlineErrorView(message: errorMessage, retryTitle: "Retry Tokens") {
 70                        Task {
 71                            await viewModel.loadTokens(client: client)
 72                        }
 73                    }
 74                    .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
 75                }
 76            }
 77
 78            if viewModel.isLoading && viewModel.tokens.isEmpty {
 79                Section {
 80                    HStack {
 81                        Spacer()
 82                        ProgressView("Loading Tokens")
 83                        Spacer()
 84                    }
 85                }
 86            } else if viewModel.tokens.isEmpty {
 87                Section {
 88                    ContentUnavailableView(
 89                        "No Tokens",
 90                        systemImage: "key.horizontal",
 91                        description: Text("No API tokens found. Create a restricted token for specific access.")
 92                    )
 93                }
 94            } else {
 95                ForEach(viewModel.tokens) { token in
 96                    TokenRow(token: token, label: viewModel.tokenLabel(for: token))
 97                        .contentShape(Rectangle())
 98                        .contextMenu {
 99                            Button("Delete Token", role: .destructive) {
100                                tokenPendingDeletion = token
101                            }
102                        }
103                        .disabled(viewModel.isSaving)
104                        .opacity(viewModel.isSaving ? 0.6 : 1)
105                        .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
106                }
107            }
108        }
109        .listStyle(.insetGrouped)
110        .refreshable {
111            await viewModel.loadTokens(client: client)
112        }
113        .overlay(alignment: .top) {
114            if viewModel.isLoading && !viewModel.tokens.isEmpty {
115                ProgressView()
116                    .padding(.top, 8)
117            }
118        }
119    }
120
121    private var deletionTitle: String {
122        guard let tokenPendingDeletion else {
123            return ""
124        }
125
126        return "Delete token \(viewModel.tokenLabel(for: tokenPendingDeletion))?"
127    }
128
129    private var deleteBinding: Binding<Bool> {
130        Binding(
131            get: { tokenPendingDeletion != nil },
132            set: { newValue in
133                if !newValue {
134                    tokenPendingDeletion = nil
135                }
136            }
137        )
138    }
139
140    private var mutationErrorBinding: Binding<Bool> {
141        Binding(
142            get: { viewModel.mutationErrorMessage != nil },
143            set: { newValue in
144                if !newValue {
145                    viewModel.dismissMutationError()
146                }
147            }
148        )
149    }
150}
151
152private struct TokenRow: View {
153    let token: APIToken
154    let label: String
155
156    var body: some View {
157        VStack(alignment: .leading, spacing: 6) {
158            Text(label)
159                .font(.headline)
160
161            Text(methodsText)
162                .font(.subheadline)
163                .foregroundStyle(.secondary)
164
165            if let from = token.from, !from.isEmpty {
166                Text("From: \(from.joined(separator: ", "))")
167                    .font(.subheadline)
168                    .foregroundStyle(.secondary)
169            }
170
171            if let domains = token.allowedDomains, !domains.isEmpty {
172                Text("Domains: \(domains.joined(separator: ", "))")
173                    .font(.subheadline)
174                    .foregroundStyle(.secondary)
175            }
176        }
177        .padding(.vertical, 4)
178    }
179
180    private var methodsText: String {
181        guard let methods = token.allowedMethods, !methods.isEmpty else {
182            return "Methods: Unrestricted"
183        }
184
185        return "Methods: \(methods.joined(separator: ", "))"
186    }
187}