gitbay/Views/Admin/AdminAccountView.swift
258 lines · 10660 bytes
11 symbols in this file
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}