import SwiftUI /// One account as an instance admin sees it, `admin user show`: its keys, /// addresses, organizations, tokens and sessions, its caps with what it /// holds against them, and deletion. struct AdminAccountView: View { @State private var model: AdminAccountViewModel @State private var editingLimits = false @State private var deleting = false @State private var typed = "" @State private var deleted = false @State private var refusal: String? init(client: GitbayClient, username: String) { _model = State(initialValue: AdminAccountViewModel(client: client, username: username)) } var body: some View { List { if deleted { Section { Label("\(model.username) is deleted", systemImage: "trash") .font(.gbSans(.subheadline)) .accessibilityIdentifier("admin-account-deleted") } } else if let loaded = model.state.value { header(loaded.detail) if let limits = loaded.limits { limitsSection(limits) } else if let why = loaded.limitsError { Section("Limits") { GBNotice(why, .gbWarn) } } keysSection(loaded.detail) emailsSection(loaded.detail) if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) } if !loaded.detail.apiTokens.isEmpty { tokensSection(loaded.detail) } Section { Button(role: .destructive) { typed = "" deleting = true } label: { Label("Delete account", systemImage: "trash") .font(.gbSans(.subheadline)) } .disabled(model.working) .accessibilityIdentifier("admin-account-delete") } footer: { 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.") } } } .overlay { if !deleted { LoadStateOverlay(state: model.state) } } .navigationTitle(model.username) .navigationBarTitleDisplayMode(.inline) .task { if !deleted { await model.load() } } .refreshable { if !deleted { await model.load() } } .sheet(isPresented: $editingLimits) { LimitsSheet(heading: "Limits for \(model.username)", hasOrgs: true, working: model.working, error: model.actionError) { repos, megabytes, orgs in if await model.setLimits(repos: repos, megabytes: megabytes, orgs: orgs) { editingLimits = false } } } .alert("Delete \(model.username)?", isPresented: $deleting) { TextField(model.username, text: $typed) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Delete", role: .destructive) { Task { if await model.delete() { deleted = true NotificationCenter.default.post(name: .gitbayAccountsChanged, object: nil) } else { refusal = model.actionError } } } .disabled(typed.trimmingCharacters(in: .whitespaces) != model.username) Button("Cancel", role: .cancel) {} } message: { Text("Its keys, addresses and sessions go with it. Type the username to confirm.") } .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) { Button("OK", role: .cancel) {} } message: { Text(refusal ?? "") } } private func header(_ d: AdminAccountDetail) -> some View { Section { HStack(spacing: 6) { Text(d.username).font(.gbSans(.headline)) if d.admin { GBChip("admin", .secondary) } GBChip(d.state, d.state == "active" ? .gbOK : d.state == "disabled" ? .gbBad : .secondary) } LabeledContent("Joined", value: d.createdAt.formatted(date: .abbreviated, time: .omitted)) LabeledContent("Repositories", value: String(d.repos)) LabeledContent("Web sessions", value: String(d.webSessions)) if let after = d.deleteAfter { LabeledContent("Deletes after", value: (try? Date(after, strategy: .iso8601)) .map { $0.formatted(date: .abbreviated, time: .shortened) } ?? after) .foregroundStyle(Color.gbBad) } } .font(.gbSans(.subheadline)) } private func limitsSection(_ l: OwnerLimits) -> some View { Section { LabeledContent("Repositories", value: "\(l.reposOwned) of \(cap(l.repos))") LabeledContent("Storage", value: "\(bytes(l.bytesOwned)) of \(l.bytes == 0 ? "unlimited" : bytes(l.bytes))") if let orgs = l.orgs { LabeledContent("Organizations", value: "\(l.orgsCreated ?? 0) of \(cap(orgs))") } Button("Edit limits") { editingLimits = true } .disabled(model.working) .accessibilityIdentifier("admin-account-limits-edit") } header: { Text("Limits") } footer: { Text(l.override ? "Set on this account." : "The instance's defaults.") } .font(.gbSans(.subheadline)) } private func keysSection(_ d: AdminAccountDetail) -> some View { Section("SSH keys") { if d.keys.isEmpty { Text("None").foregroundStyle(.secondary) } ForEach(d.keys) { key in VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(key.label.isEmpty ? key.algo : key.label).font(.gbSans(.subheadline).weight(.medium)) GBChip(key.scope, .secondary) } Text(key.fingerprint) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.middle) Text(key.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } } .font(.gbSans(.subheadline)) } private func emailsSection(_ d: AdminAccountDetail) -> some View { Section("Email") { if d.emails.isEmpty { Text("None").foregroundStyle(.secondary) } ForEach(d.emails) { email in HStack(spacing: 6) { Text(email.address) Spacer() if email.primary { GBChip("primary", .secondary) } GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn) } } } .font(.gbSans(.subheadline)) } private func orgsSection(_ d: AdminAccountDetail) -> some View { Section("Organizations") { ForEach(d.orgs) { m in LabeledContent(m.org, value: m.role) } } .font(.gbSans(.subheadline)) } private func tokensSection(_ d: AdminAccountDetail) -> some View { Section("API tokens") { ForEach(d.apiTokens) { t in HStack(spacing: 6) { Text(t.name) GBChip(t.scope, .secondary) Spacer() Text(t.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } } .font(.gbSans(.subheadline)) } private func cap(_ n: Int64) -> String { n == 0 ? "unlimited" : String(n) } private func bytes(_ n: Int64) -> String { n.formatted(.byteCount(style: .file, spellsOutZero: false)) } } /// Caps for an account or an organization. An empty field stays as it /// is; `default` returns it to the instance's; 0 is unlimited. struct LimitsSheet: View { let heading: String let hasOrgs: Bool let working: Bool let error: String? let save: (String, String, String) async -> Void @State private var repos = "" @State private var megabytes = "" @State private var orgs = "" @State private var sent = false @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { Section { field("Repositories", $repos, id: "limits-repos") field("Storage, MB", $megabytes, id: "limits-mb") if hasOrgs { field("Organizations", $orgs, id: "limits-orgs") } } footer: { Text("Leave a field empty to keep it. A number sets the cap, 0 for unlimited; default takes the instance's.") } if sent, let error { Section { GBNotice(error) } } } .navigationTitle(heading) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if working { ProgressView() } else { Button("Save") { sent = true Task { await save(repos, megabytes, orgs) } } .accessibilityIdentifier("limits-save") } } } } } private func field(_ title: String, _ text: Binding, id: String) -> some View { LabeledContent(title) { TextField("unchanged", text: text) .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier(id) } .font(.gbSans(.subheadline)) } }