diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index dce12f9..7b68793 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -3155,36 +3155,6 @@ struct WorkbenchAdministrationView: View { processControls } - if page == .users && !result.rows.isEmpty { - HStack { - Picker("Account", selection: $selectedAccount) { - Text("Select account").tag(String?.none) - ForEach(result.rows.indices, id: \.self) { i in - let row = result.rows[i] - if row.count >= 2 { - Text("\(row[0])@\(row[1])") - .tag(Optional("\(row[0])\u{001f}\(row[1])")) - } - } - } - .frame(maxWidth: 320) - Button("Show Grants") { - Task { await showGrants() } - } - .disabled(selectedAccount == nil) - Button("Manage Accounts…") { - autoRefresh = false - showingAccountManager = true - } - .disabled(profile == nil) - Spacer() - Text("Changes require confirmation") - .foregroundStyle(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - Divider() footer @@ -3466,6 +3436,8 @@ struct WorkbenchAdministrationView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityIdentifier("scrollable-dashboard-phase51") + case .users: + accountAdministration case .serverLogs: VStack(alignment: .leading, spacing: 0) { HStack { @@ -3516,6 +3488,108 @@ struct WorkbenchAdministrationView: View { } } + // Workbench-style account administration: persistent account list and inspector. + private var accountAdministration: some View { + HStack(spacing: 0) { + VStack(spacing: 0) { + HStack { + Label("User Accounts", systemImage: "person.2") + .font(.headline) + Spacer() + Text("\(filteredRows.count)") + .foregroundStyle(.secondary) + } + .padding(12) + Divider() + List(selection: $selectedAccount) { + ForEach(filteredRows.indices, id: \.self) { index in + let row = filteredRows[index] + if row.count >= 2 { + let key = "\(row[0])\u{001f}\(row[1])" + VStack(alignment: .leading, spacing: 4) { + Label(row[0].isEmpty ? "(anonymous)" : row[0], systemImage: "person.crop.circle") + .font(.body.weight(.medium)) + Text("Host: \(row[1])") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .tag(key) + } + } + } + .listStyle(.sidebar) + Divider() + HStack { + Button { + selectedAccount = nil + showingAccountManager = true + } label: { Image(systemName: "plus") } + .help("Create MySQL account") + Spacer() + Button { Task { await refresh() } } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh accounts") + } + .buttonStyle(.borderless) + .padding(12) + } + .frame(minWidth: 205, idealWidth: 245, maxWidth: 290) + Divider() + VStack(alignment: .leading, spacing: 0) { + if let selectedAccount, + let row = result.rows.first(where: { + $0.count >= 2 && "\($0[0])\u{001f}\($0[1])" == selectedAccount + }) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(row[0].isEmpty ? "Anonymous account" : row[0]) + .font(.title2.bold()) + Text("'\(row[0])'@'\(row[1])'") + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + Button("Edit Account…") { + showingAccountManager = true + } + .disabled(profile == nil) + } + .padding(18) + Divider() + Form { + Section("Login") { + LabeledContent("User", value: row[0]) + LabeledContent("Host", value: row[1]) + if row.count > 2 { LabeledContent("Authentication plugin", value: row[2]) } + if row.count > 3 { + LabeledContent("Account status", value: row[3] == "Y" ? "Locked" : "Active") + } + } + Section("Administrative Actions") { + HStack { + Button("View Grants…") { Task { await showGrants() } } + Button("Manage Privileges…") { showingAccountManager = true } + Button("Change Password…") { showingAccountManager = true } + } + Text("Changes require MySQL administrative privileges and explicit confirmation.") + .font(.caption).foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + Spacer(minLength: 0) + } else { + ContentUnavailableView("Select a MySQL Account", systemImage: "person.crop.circle.badge.checkmark", description: Text("Choose an account on the left to inspect its login, authentication and privileges.")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func logValue(_ name: String, in row: [String]) -> String { guard let index = result.columns.firstIndex(where: { $0.caseInsensitiveCompare(name) == .orderedSame }), index < row.count else { return "" } @@ -4963,18 +5037,30 @@ private struct WorkbenchAccountManagerView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("Manage MySQL Accounts").font(.title2.bold()) - Text("Account changes run on \(profile.name). Review the SQL before applying it.") + HStack { + Label("Users and Privileges", systemImage: "person.crop.circle.badge.checkmark") + .font(.title2.bold()) + Spacer() + Text(profile.name).foregroundStyle(.secondary) + } + Text("Manage login credentials, account access and schema privileges. Every change requires SQL review and confirmation.") .foregroundStyle(.secondary) Form { - Picker("Operation", selection: $action) { - ForEach(WorkbenchAccountAction.allCases) { option in - Text(option.rawValue).tag(option) - } + Section("Account") { + TextField("Username", text: $username) + .textContentType(.username) + TextField("Host", text: $hostname) + Text("MySQL accounts are identified by both username and host.") + .font(.caption).foregroundStyle(.secondary) + } + Section("Account Management") { + Picker("Operation", selection: $action) { + ForEach(WorkbenchAccountAction.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.menu) } - TextField("Username", text: $username) - .textContentType(.username) - TextField("Host", text: $hostname) if action == .create || action == .password { SecureField("Password", text: $password) }