import SwiftUI /// The web's /admin/users: every account, filtered by state, with the /// role and access action each row allows. Demote and disable ask for /// the username typed, as the web does. struct AdminUsersView: View { @Environment(\.pushRoute) private var pushRoute @State private var model: AdminUsersViewModel @State private var pending: Pending? @State private var typed = "" @State private var creating = false @State private var inviting = false @State private var inviteNote: String? nonisolated struct Pending: Hashable, Sendable { let action: AdminAction let username: String } init(client: GitbayClient) { _model = State(initialValue: AdminUsersViewModel(client: client)) } var body: some View { List { Section { Picker("State", selection: Bindable(model).filter) { ForEach(AdminUserFilter.allCases) { filter in Text(filter.label).tag(filter) } } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) .accessibilityIdentifier("admin-user-filter") } if let error = model.actionError, !creating { Section { GBNotice(error, .gbWarn) } } if let note = inviteNote { Section { GBNotice(note, .gbOK) .textSelection(.enabled) } } ForEach(model.list.state.value ?? []) { user in row(user) } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.list.state) } .navigationTitle("Accounts") .navigationBarTitleDisplayMode(.inline) .task { await model.loadIfNeeded() } .refreshable { await model.load() } .onReceive(NotificationCenter.default.publisher(for: .gitbayAccountsChanged)) { _ in Task { await model.load() } } .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { Button { creating = true } label: { Label("Create account", systemImage: "person.badge.plus") } Button { inviting = true } label: { Label("Invite by email", systemImage: "envelope") } } label: { Image(systemName: "plus") } .accessibilityLabel("Add an account") .accessibilityIdentifier("admin-accounts-add") } } .sheet(isPresented: $creating, onDismiss: { model.clearError() }) { CreateAccountSheet(model: model) { creating = false } } .sheet(isPresented: $inviting, onDismiss: { model.clearError() }) { InviteSheet(model: model) { note in inviteNote = note inviting = false } } .alert( pending.map { "\($0.action.title) \($0.username)?" } ?? "", isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), presenting: pending ) { pending in if pending.action.needsTypedName { TextField(pending.username, text: $typed) .autocorrectionDisabled() .textInputAutocapitalization(.never) } Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) { Task { await model.perform(pending.action, on: pending.username) } } .disabled(pending.action.needsTypedName && typed.trimmingCharacters(in: .whitespaces) != pending.username) Button("Cancel", role: .cancel) {} } message: { pending in Text(pending.action.message(for: pending.username)) } } private func row(_ user: AdminUser) -> some View { HStack { // A button rather than a link: the row also holds the menu, // and a link would claim the whole row. Button { pushRoute(RepoRoute.adminUser(user.username)) } label: { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(user.username) .font(.gbSans(.subheadline).weight(.medium)) if user.admin { GBChip("admin", .secondary) } GBChip(user.state, stateColor(user.state)) } Text(seen(user)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("admin-user-\(user.username)") Spacer() Menu { Button(user.roleAction.title) { ask(user.roleAction, user) } .disabled(!user.canChangeRole || model.working) Button(user.accessAction.title, role: user.accessAction == .disable ? .destructive : nil) { ask(user.accessAction, user) } .disabled(model.working) } label: { Image(systemName: "ellipsis.circle") } .accessibilityIdentifier("admin-user-menu-\(user.username)") .accessibilityLabel("Actions for \(user.username)") } } private func ask(_ action: AdminAction, _ user: AdminUser) { typed = "" pending = Pending(action: action, username: user.username) } /// The web's chip classes: open for active, closed for disabled, /// neutral otherwise. private func stateColor(_ state: String) -> Color { switch state { case "active": .gbOK case "disabled": .gbBad default: .secondary } } private func seen(_ user: AdminUser) -> String { let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))" let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" } ?? "never seen" return "\(created) ยท \(last)" } } /// `admin user create`: a username, optionally an address (verified or /// not), admin, and a public key to sign in with. private struct CreateAccountSheet: View { let model: AdminUsersViewModel let onDone: () -> Void @State private var username = "" @State private var email = "" /// Off: a mistyped address marked verified would take sign-in links. @State private var verified = false @State private var admin = false @State private var key = "" @State private var sent = false @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { Section("Username") { TextField("username", text: $username) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("create-account-username") } Section { TextField("address (optional)", text: $email) .keyboardType(.emailAddress) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("create-account-email") Toggle("Already verified", isOn: $verified) .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty) } header: { Text("Email") } Section { TextEditor(text: $key) .font(.gbMono(.caption)) .frame(minHeight: 80) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("create-account-key") } header: { Text("SSH key (optional)") } footer: { Text("An authorized_keys line. Without a key or a verified address the account has no way to sign in.") } Section { Toggle("Instance admin", isOn: $admin) } if sent, let error = model.actionError { Section { GBNotice(error) } } } .navigationTitle("New Account") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Create") { sent = true Task { if await model.create(username: username, email: email, verified: verified, admin: admin, key: key) { onDone() } } } .disabled(username.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("create-account-submit") } } } } } } /// `admin invite --email
`: a registration code by mail. private struct InviteSheet: View { let model: AdminUsersViewModel let onDone: (String) -> Void @State private var email = "" @State private var working = false @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { Section { TextField("address", text: $email) .keyboardType(.emailAddress) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("invite-email") } footer: { Text("Mails a code that lets them register, for an address no account holds.") } if let error = model.inviteError { Section { GBNotice(error) } } } .navigationTitle("Invite") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if working { ProgressView() } else { Button("Invite") { working = true Task { model.inviteError = nil if let note = await model.invite(email: email) { onDone(note) } working = false } } .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("invite-submit") } } } } } }