gitbay/Views/Admin/AdminUsersView.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Admin/AdminUsersView.swift history · blame · raw

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}