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}