gitbay/Views/Admin/AdminAccountView.swift

v1.13.1
gitbay-ios/gitbay/Views/Admin/AdminAccountView.swift history · blame · raw

258 lines · 10660 bytes

  1import SwiftUI
  2
  3/// One account as an instance admin sees it, `admin user show`: its keys,
  4/// addresses, organizations, tokens and sessions, its caps with what it
  5/// holds against them, and deletion.
  6struct AdminAccountView: View {
  7
  8    @State private var model: AdminAccountViewModel
  9    @State private var editingLimits = false
 10    @State private var deleting = false
 11    @State private var typed = ""
 12    @State private var deleted = false
 13    @State private var refusal: String?
 14
 15    init(client: GitbayClient, username: String) {
 16        _model = State(initialValue: AdminAccountViewModel(client: client, username: username))
 17    }
 18
 19    var body: some View {
 20        List {
 21            if deleted {
 22                Section {
 23                    Label("\(model.username) is deleted", systemImage: "trash")
 24                        .font(.gbSans(.subheadline))
 25                        .accessibilityIdentifier("admin-account-deleted")
 26                }
 27            } else if let loaded = model.state.value {
 28                header(loaded.detail)
 29                if let limits = loaded.limits {
 30                    limitsSection(limits)
 31                } else if let why = loaded.limitsError {
 32                    Section("Limits") { GBNotice(why, .gbWarn) }
 33                }
 34                keysSection(loaded.detail)
 35                emailsSection(loaded.detail)
 36                if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) }
 37                if !loaded.detail.apiTokens.isEmpty { tokensSection(loaded.detail) }
 38                Section {
 39                    Button(role: .destructive) {
 40                        typed = ""
 41                        deleting = true
 42                    } label: {
 43                        Label("Delete account", systemImage: "trash")
 44                            .font(.gbSans(.subheadline))
 45                    }
 46                    .disabled(model.working)
 47                    .accessibilityIdentifier("admin-account-delete")
 48                } footer: {
 49                    Text("Only an account that anchors nothing: no repositories, no organization it is the only admin of, and no issues, comments or reviews. The refusal names what it still holds.")
 50                }
 51            }
 52        }
 53        .overlay { if !deleted { LoadStateOverlay(state: model.state) } }
 54        .navigationTitle(model.username)
 55        .navigationBarTitleDisplayMode(.inline)
 56        .task { if !deleted { await model.load() } }
 57        .refreshable { if !deleted { await model.load() } }
 58        .sheet(isPresented: $editingLimits) {
 59            LimitsSheet(heading: "Limits for \(model.username)", hasOrgs: true, working: model.working,
 60                        error: model.actionError) { repos, megabytes, orgs in
 61                if await model.setLimits(repos: repos, megabytes: megabytes, orgs: orgs) { editingLimits = false }
 62            }
 63        }
 64        .alert("Delete \(model.username)?", isPresented: $deleting) {
 65            TextField(model.username, text: $typed)
 66                .autocorrectionDisabled()
 67                .textInputAutocapitalization(.never)
 68            Button("Delete", role: .destructive) {
 69                Task {
 70                    if await model.delete() {
 71                        deleted = true
 72                        NotificationCenter.default.post(name: .gitbayAccountsChanged, object: nil)
 73                    } else {
 74                        refusal = model.actionError
 75                    }
 76                }
 77            }
 78            .disabled(typed.trimmingCharacters(in: .whitespaces) != model.username)
 79            Button("Cancel", role: .cancel) {}
 80        } message: {
 81            Text("Its keys, addresses and sessions go with it. Type the username to confirm.")
 82        }
 83        .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
 84            Button("OK", role: .cancel) {}
 85        } message: {
 86            Text(refusal ?? "")
 87        }
 88    }
 89
 90    private func header(_ d: AdminAccountDetail) -> some View {
 91        Section {
 92            HStack(spacing: 6) {
 93                Text(d.username).font(.gbSans(.headline))
 94                if d.admin { GBChip("admin", .secondary) }
 95                GBChip(d.state, d.state == "active" ? .gbOK : d.state == "disabled" ? .gbBad : .secondary)
 96            }
 97            LabeledContent("Joined", value: d.createdAt.formatted(date: .abbreviated, time: .omitted))
 98            LabeledContent("Repositories", value: String(d.repos))
 99            LabeledContent("Web sessions", value: String(d.webSessions))
100            if let after = d.deleteAfter {
101                LabeledContent("Deletes after", value: (try? Date(after, strategy: .iso8601))
102                    .map { $0.formatted(date: .abbreviated, time: .shortened) } ?? after)
103                    .foregroundStyle(Color.gbBad)
104            }
105        }
106        .font(.gbSans(.subheadline))
107    }
108
109    private func limitsSection(_ l: OwnerLimits) -> some View {
110        Section {
111            LabeledContent("Repositories", value: "\(l.reposOwned) of \(cap(l.repos))")
112            LabeledContent("Storage", value: "\(bytes(l.bytesOwned)) of \(l.bytes == 0 ? "unlimited" : bytes(l.bytes))")
113            if let orgs = l.orgs {
114                LabeledContent("Organizations", value: "\(l.orgsCreated ?? 0) of \(cap(orgs))")
115            }
116            Button("Edit limits") { editingLimits = true }
117                .disabled(model.working)
118                .accessibilityIdentifier("admin-account-limits-edit")
119        } header: {
120            Text("Limits")
121        } footer: {
122            Text(l.override ? "Set on this account." : "The instance's defaults.")
123        }
124        .font(.gbSans(.subheadline))
125    }
126
127    private func keysSection(_ d: AdminAccountDetail) -> some View {
128        Section("SSH keys") {
129            if d.keys.isEmpty {
130                Text("None").foregroundStyle(.secondary)
131            }
132            ForEach(d.keys) { key in
133                VStack(alignment: .leading, spacing: 2) {
134                    HStack(spacing: 6) {
135                        Text(key.label.isEmpty ? key.algo : key.label).font(.gbSans(.subheadline).weight(.medium))
136                        GBChip(key.scope, .secondary)
137                    }
138                    Text(key.fingerprint)
139                        .font(.gbMono(.caption))
140                        .lineLimit(1)
141                        .truncationMode(.middle)
142                    Text(key.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used")
143                        .font(.gbSans(.caption2))
144                        .foregroundStyle(.secondary)
145                }
146            }
147        }
148        .font(.gbSans(.subheadline))
149    }
150
151    private func emailsSection(_ d: AdminAccountDetail) -> some View {
152        Section("Email") {
153            if d.emails.isEmpty {
154                Text("None").foregroundStyle(.secondary)
155            }
156            ForEach(d.emails) { email in
157                HStack(spacing: 6) {
158                    Text(email.address)
159                    Spacer()
160                    if email.primary { GBChip("primary", .secondary) }
161                    GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn)
162                }
163            }
164        }
165        .font(.gbSans(.subheadline))
166    }
167
168    private func orgsSection(_ d: AdminAccountDetail) -> some View {
169        Section("Organizations") {
170            ForEach(d.orgs) { m in
171                LabeledContent(m.org, value: m.role)
172            }
173        }
174        .font(.gbSans(.subheadline))
175    }
176
177    private func tokensSection(_ d: AdminAccountDetail) -> some View {
178        Section("API tokens") {
179            ForEach(d.apiTokens) { t in
180                HStack(spacing: 6) {
181                    Text(t.name)
182                    GBChip(t.scope, .secondary)
183                    Spacer()
184                    Text(t.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used")
185                        .font(.gbSans(.caption2))
186                        .foregroundStyle(.secondary)
187                }
188            }
189        }
190        .font(.gbSans(.subheadline))
191    }
192
193    private func cap(_ n: Int64) -> String { n == 0 ? "unlimited" : String(n) }
194    private func bytes(_ n: Int64) -> String { n.formatted(.byteCount(style: .file, spellsOutZero: false)) }
195}
196
197/// Caps for an account or an organization. An empty field stays as it
198/// is; `default` returns it to the instance's; 0 is unlimited.
199struct LimitsSheet: View {
200
201    let heading: String
202    let hasOrgs: Bool
203    let working: Bool
204    let error: String?
205    let save: (String, String, String) async -> Void
206
207    @State private var repos = ""
208    @State private var megabytes = ""
209    @State private var orgs = ""
210    @State private var sent = false
211    @Environment(\.dismiss) private var dismiss
212
213    var body: some View {
214        NavigationStack {
215            Form {
216                Section {
217                    field("Repositories", $repos, id: "limits-repos")
218                    field("Storage, MB", $megabytes, id: "limits-mb")
219                    if hasOrgs { field("Organizations", $orgs, id: "limits-orgs") }
220                } footer: {
221                    Text("Leave a field empty to keep it. A number sets the cap, 0 for unlimited; default takes the instance's.")
222                }
223                if sent, let error {
224                    Section { GBNotice(error) }
225                }
226            }
227            .navigationTitle(heading)
228            .navigationBarTitleDisplayMode(.inline)
229            .toolbar {
230                ToolbarItem(placement: .cancellationAction) {
231                    Button("Cancel") { dismiss() }
232                }
233                ToolbarItem(placement: .confirmationAction) {
234                    if working {
235                        ProgressView()
236                    } else {
237                        Button("Save") {
238                            sent = true
239                            Task { await save(repos, megabytes, orgs) }
240                        }
241                        .accessibilityIdentifier("limits-save")
242                    }
243                }
244            }
245        }
246    }
247
248    private func field(_ title: String, _ text: Binding<String>, id: String) -> some View {
249        LabeledContent(title) {
250            TextField("unchanged", text: text)
251                .multilineTextAlignment(.trailing)
252                .autocorrectionDisabled()
253                .textInputAutocapitalization(.never)
254                .accessibilityIdentifier(id)
255        }
256        .font(.gbSans(.subheadline))
257    }
258}