gitbay/Views/Admin/AdminUsersView.swift
309 lines · 11940 bytes
7 symbols in this file
1import SwiftUI
2
3/// The web's /admin/users: every account, filtered by state, with the
4/// role and access action each row allows. Demote and disable ask for
5/// the username typed, as the web does.
6struct AdminUsersView: View {
7
8 @Environment(\.pushRoute) private var pushRoute
9 @State private var model: AdminUsersViewModel
10 @State private var pending: Pending?
11 @State private var typed = ""
12 @State private var creating = false
13 @State private var inviting = false
14 @State private var inviteNote: String?
15
16 nonisolated struct Pending: Hashable, Sendable {
17 let action: AdminAction
18 let username: String
19 }
20
21 init(client: GitbayClient) {
22 _model = State(initialValue: AdminUsersViewModel(client: client))
23 }
24
25 var body: some View {
26 List {
27 Section {
28 Picker("State", selection: Bindable(model).filter) {
29 ForEach(AdminUserFilter.allCases) { filter in
30 Text(filter.label).tag(filter)
31 }
32 }
33 .pickerStyle(.segmented)
34 .listRowBackground(Color.clear)
35 .listRowInsets(EdgeInsets())
36 .accessibilityIdentifier("admin-user-filter")
37 }
38
39 if let error = model.actionError, !creating {
40 Section {
41 GBNotice(error, .gbWarn)
42 }
43 }
44 if let note = inviteNote {
45 Section {
46 GBNotice(note, .gbOK)
47 .textSelection(.enabled)
48 }
49 }
50 ForEach(model.list.state.value ?? []) { user in
51 row(user)
52 }
53 PageFooter(list: model.list)
54 }
55 .overlay { LoadStateOverlay(state: model.list.state) }
56 .navigationTitle("Accounts")
57 .navigationBarTitleDisplayMode(.inline)
58 .task { await model.loadIfNeeded() }
59 .refreshable { await model.load() }
60 .onReceive(NotificationCenter.default.publisher(for: .gitbayAccountsChanged)) { _ in
61 Task { await model.load() }
62 }
63 .toolbar {
64 ToolbarItem(placement: .topBarTrailing) {
65 Menu {
66 Button {
67 creating = true
68 } label: {
69 Label("Create account", systemImage: "person.badge.plus")
70 }
71 Button {
72 inviting = true
73 } label: {
74 Label("Invite by email", systemImage: "envelope")
75 }
76 } label: {
77 Image(systemName: "plus")
78 }
79 .accessibilityLabel("Add an account")
80 .accessibilityIdentifier("admin-accounts-add")
81 }
82 }
83 .sheet(isPresented: $creating, onDismiss: { model.clearError() }) {
84 CreateAccountSheet(model: model) { creating = false }
85 }
86 .sheet(isPresented: $inviting, onDismiss: { model.clearError() }) {
87 InviteSheet(model: model) { note in
88 inviteNote = note
89 inviting = false
90 }
91 }
92 .alert(
93 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
94 isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
95 presenting: pending
96 ) { pending in
97 if pending.action.needsTypedName {
98 TextField(pending.username, text: $typed)
99 .autocorrectionDisabled()
100 .textInputAutocapitalization(.never)
101 }
102 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
103 Task { await model.perform(pending.action, on: pending.username) }
104 }
105 .disabled(pending.action.needsTypedName
106 && typed.trimmingCharacters(in: .whitespaces) != pending.username)
107 Button("Cancel", role: .cancel) {}
108 } message: { pending in
109 Text(pending.action.message(for: pending.username))
110 }
111 }
112
113 private func row(_ user: AdminUser) -> some View {
114 HStack {
115 // A button rather than a link: the row also holds the menu,
116 // and a link would claim the whole row.
117 Button {
118 pushRoute(RepoRoute.adminUser(user.username))
119 } label: {
120 VStack(alignment: .leading, spacing: 3) {
121 HStack(spacing: 6) {
122 Text(user.username)
123 .font(.gbSans(.subheadline).weight(.medium))
124 if user.admin {
125 GBChip("admin", .secondary)
126 }
127 GBChip(user.state, stateColor(user.state))
128 }
129 Text(seen(user))
130 .font(.gbSans(.caption))
131 .foregroundStyle(.secondary)
132 }
133 .contentShape(Rectangle())
134 }
135 .buttonStyle(.plain)
136 .accessibilityIdentifier("admin-user-\(user.username)")
137 Spacer()
138 Menu {
139 Button(user.roleAction.title) { ask(user.roleAction, user) }
140 .disabled(!user.canChangeRole || model.working)
141 Button(user.accessAction.title,
142 role: user.accessAction == .disable ? .destructive : nil) {
143 ask(user.accessAction, user)
144 }
145 .disabled(model.working)
146 } label: {
147 Image(systemName: "ellipsis.circle")
148 }
149 .accessibilityIdentifier("admin-user-menu-\(user.username)")
150 .accessibilityLabel("Actions for \(user.username)")
151 }
152 }
153
154 private func ask(_ action: AdminAction, _ user: AdminUser) {
155 typed = ""
156 pending = Pending(action: action, username: user.username)
157 }
158
159 /// The web's chip classes: open for active, closed for disabled,
160 /// neutral otherwise.
161 private func stateColor(_ state: String) -> Color {
162 switch state {
163 case "active": .gbOK
164 case "disabled": .gbBad
165 default: .secondary
166 }
167 }
168
169 private func seen(_ user: AdminUser) -> String {
170 let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
171 let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" }
172 ?? "never seen"
173 return "\(created) · \(last)"
174 }
175}
176
177/// `admin user create`: a username, optionally an address (verified or
178/// not), admin, and a public key to sign in with.
179private struct CreateAccountSheet: View {
180
181 let model: AdminUsersViewModel
182 let onDone: () -> Void
183
184 @State private var username = ""
185 @State private var email = ""
186 /// Off: a mistyped address marked verified would take sign-in links.
187 @State private var verified = false
188 @State private var admin = false
189 @State private var key = ""
190 @State private var sent = false
191 @Environment(\.dismiss) private var dismiss
192
193 var body: some View {
194 NavigationStack {
195 Form {
196 Section("Username") {
197 TextField("username", text: $username)
198 .autocorrectionDisabled()
199 .textInputAutocapitalization(.never)
200 .accessibilityIdentifier("create-account-username")
201 }
202 Section {
203 TextField("address (optional)", text: $email)
204 .keyboardType(.emailAddress)
205 .autocorrectionDisabled()
206 .textInputAutocapitalization(.never)
207 .accessibilityIdentifier("create-account-email")
208 Toggle("Already verified", isOn: $verified)
209 .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty)
210 } header: {
211 Text("Email")
212 }
213 Section {
214 TextEditor(text: $key)
215 .font(.gbMono(.caption))
216 .frame(minHeight: 80)
217 .autocorrectionDisabled()
218 .textInputAutocapitalization(.never)
219 .accessibilityIdentifier("create-account-key")
220 } header: {
221 Text("SSH key (optional)")
222 } footer: {
223 Text("An authorized_keys line. Without a key or a verified address the account has no way to sign in.")
224 }
225 Section {
226 Toggle("Instance admin", isOn: $admin)
227 }
228 if sent, let error = model.actionError {
229 Section { GBNotice(error) }
230 }
231 }
232 .navigationTitle("New Account")
233 .navigationBarTitleDisplayMode(.inline)
234 .toolbar {
235 ToolbarItem(placement: .cancellationAction) {
236 Button("Cancel") { dismiss() }
237 }
238 ToolbarItem(placement: .confirmationAction) {
239 if model.working {
240 ProgressView()
241 } else {
242 Button("Create") {
243 sent = true
244 Task {
245 if await model.create(username: username, email: email, verified: verified,
246 admin: admin, key: key) { onDone() }
247 }
248 }
249 .disabled(username.trimmingCharacters(in: .whitespaces).isEmpty)
250 .accessibilityIdentifier("create-account-submit")
251 }
252 }
253 }
254 }
255 }
256}
257
258/// `admin invite --email <address>`: a registration code by mail.
259private struct InviteSheet: View {
260
261 let model: AdminUsersViewModel
262 let onDone: (String) -> Void
263
264 @State private var email = ""
265 @State private var working = false
266 @Environment(\.dismiss) private var dismiss
267
268 var body: some View {
269 NavigationStack {
270 Form {
271 Section {
272 TextField("address", text: $email)
273 .keyboardType(.emailAddress)
274 .autocorrectionDisabled()
275 .textInputAutocapitalization(.never)
276 .accessibilityIdentifier("invite-email")
277 } footer: {
278 Text("Mails a code that lets them register, for an address no account holds.")
279 }
280 if let error = model.inviteError {
281 Section { GBNotice(error) }
282 }
283 }
284 .navigationTitle("Invite")
285 .navigationBarTitleDisplayMode(.inline)
286 .toolbar {
287 ToolbarItem(placement: .cancellationAction) {
288 Button("Cancel") { dismiss() }
289 }
290 ToolbarItem(placement: .confirmationAction) {
291 if working {
292 ProgressView()
293 } else {
294 Button("Invite") {
295 working = true
296 Task {
297 model.inviteError = nil
298 if let note = await model.invite(email: email) { onDone(note) }
299 working = false
300 }
301 }
302 .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty)
303 .accessibilityIdentifier("invite-submit")
304 }
305 }
306 }
307 }
308 }
309}