Improved progress display
Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
This commit is contained in:
@@ -1370,6 +1370,7 @@ struct WorkbenchAdministrationView: View {
|
||||
@State private var grants: [String] = []
|
||||
@State private var grantsError: String? = nil
|
||||
@State private var showingGrants = false
|
||||
@State private var showingAccountManager = false
|
||||
|
||||
private var filteredRows: [[String]] {
|
||||
guard !searchText.isEmpty else { return result.rows }
|
||||
@@ -1434,8 +1435,13 @@ struct WorkbenchAdministrationView: View {
|
||||
Task { await showGrants() }
|
||||
}
|
||||
.disabled(selectedAccount == nil)
|
||||
Button("Manage Accounts…") {
|
||||
autoRefresh = false
|
||||
showingAccountManager = true
|
||||
}
|
||||
.disabled(profile == nil)
|
||||
Spacer()
|
||||
Text("Read-only account inspection")
|
||||
Text("Changes require confirmation")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -1457,6 +1463,16 @@ struct WorkbenchAdministrationView: View {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAccountManager, onDismiss: {
|
||||
Task { await refresh() }
|
||||
}) {
|
||||
if let profile {
|
||||
WorkbenchAccountManagerView(
|
||||
profile: profile,
|
||||
selectedAccount: selectedAccount
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingGrants) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Grants: \(selectedAccount ?? "")")
|
||||
@@ -2897,3 +2913,183 @@ struct WorkbenchBackupManifest: Codable {
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Phase 14: Explicitly confirmed MySQL account administration
|
||||
|
||||
private enum WorkbenchAccountAction: String, CaseIterable, Identifiable {
|
||||
case create = "Create Account"
|
||||
case password = "Change Password"
|
||||
case lock = "Lock Account"
|
||||
case unlock = "Unlock Account"
|
||||
case grant = "Grant Privileges"
|
||||
case revoke = "Revoke Privileges"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private struct WorkbenchAccountManagerView: View {
|
||||
let profile: ConnectionProfile
|
||||
let selectedAccount: String?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var action: WorkbenchAccountAction = .create
|
||||
@State private var username = ""
|
||||
@State private var hostname = "%"
|
||||
@State private var password = ""
|
||||
@State private var databaseName = ""
|
||||
@State private var privilege = "SELECT"
|
||||
@State private var previewSQL = ""
|
||||
@State private var confirm = false
|
||||
@State private var running = false
|
||||
@State private var status: String?
|
||||
@State private var failure: String?
|
||||
|
||||
private let privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "INDEX", "REFERENCES", "EXECUTE", "CREATE VIEW", "SHOW VIEW", "TRIGGER"]
|
||||
|
||||
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.")
|
||||
.foregroundStyle(.secondary)
|
||||
Form {
|
||||
Picker("Operation", selection: $action) {
|
||||
ForEach(WorkbenchAccountAction.allCases) { option in
|
||||
Text(option.rawValue).tag(option)
|
||||
}
|
||||
}
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
TextField("Host", text: $hostname)
|
||||
if action == .create || action == .password {
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
if action == .grant || action == .revoke {
|
||||
TextField("Database", text: $databaseName)
|
||||
Picker("Privilege", selection: $privilege) {
|
||||
ForEach(privileges, id: \.self) { item in
|
||||
Text(item).tag(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
if let failure {
|
||||
Text(failure).foregroundStyle(.red).textSelection(.enabled)
|
||||
}
|
||||
if let status {
|
||||
Text(status).foregroundStyle(.green)
|
||||
}
|
||||
HStack {
|
||||
Button("Close") { dismiss() }
|
||||
Spacer()
|
||||
Button("Review SQL…") { review() }
|
||||
.disabled(running)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 590)
|
||||
.onAppear {
|
||||
if let selectedAccount {
|
||||
let parts = selectedAccount.split(separator: "\u{001f}", omittingEmptySubsequences: false)
|
||||
if parts.count == 2 {
|
||||
username = String(parts[0])
|
||||
hostname = String(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Confirm \(action.rawValue)", isPresented: $confirm) {
|
||||
Button("Cancel", role: .cancel) { previewSQL = "" }
|
||||
Button("Apply Change", role: .destructive) {
|
||||
Task { await execute() }
|
||||
}
|
||||
} message: {
|
||||
// Passwords are never shown in the SQL preview.
|
||||
Text("Run this operation against \(profile.name)?\n\n\(previewSQL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Strict account and database validation keeps identifiers out of SQL syntax.
|
||||
private func sql() throws -> (command: String, preview: String) {
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-%")
|
||||
guard !username.isEmpty, username.unicodeScalars.allSatisfy({ allowed.contains($0) }),
|
||||
!hostname.isEmpty, hostname.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
|
||||
throw AccountError.invalid("Username/host may contain only letters, digits, _, ., -, and %.")
|
||||
}
|
||||
let account = "'\(username)'@'\(hostname)'"
|
||||
switch action {
|
||||
case .create, .password:
|
||||
guard !password.isEmpty else { throw AccountError.invalid("Enter a password.") }
|
||||
// MySQL's default SQL mode interprets backslashes as escapes.
|
||||
// We check SQL mode before execution to avoid changing password meaning.
|
||||
let literal = password.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "''")
|
||||
let prefix = action == .create ? "CREATE USER" : "ALTER USER"
|
||||
return ("\(prefix) \(account) IDENTIFIED BY '\(literal)'",
|
||||
"\(prefix) \(account) IDENTIFIED BY '••••••••'")
|
||||
case .lock:
|
||||
return ("ALTER USER \(account) ACCOUNT LOCK", "ALTER USER \(account) ACCOUNT LOCK")
|
||||
case .unlock:
|
||||
return ("ALTER USER \(account) ACCOUNT UNLOCK", "ALTER USER \(account) ACCOUNT UNLOCK")
|
||||
case .grant, .revoke:
|
||||
guard !databaseName.isEmpty,
|
||||
databaseName.unicodeScalars.allSatisfy({ allowed.contains($0) }),
|
||||
privileges.contains(privilege) else {
|
||||
throw AccountError.invalid("Enter a valid database name and privilege.")
|
||||
}
|
||||
let db = "`\(databaseName)`.*"
|
||||
let command = action == .grant
|
||||
? "GRANT \(privilege) ON \(db) TO \(account)"
|
||||
: "REVOKE \(privilege) ON \(db) FROM \(account)"
|
||||
return (command, command)
|
||||
}
|
||||
}
|
||||
|
||||
private func review() {
|
||||
failure = nil
|
||||
status = nil
|
||||
do {
|
||||
previewSQL = try sql().preview
|
||||
confirm = true
|
||||
} catch {
|
||||
failure = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func execute() async {
|
||||
running = true
|
||||
defer { running = false; password = "" }
|
||||
do {
|
||||
let command = try sql().command
|
||||
let credentials = PasswordStore.read(profile.id)
|
||||
if action == .create || action == .password {
|
||||
let mode = try await NativeMySQLRunner.run(
|
||||
profile: profile, database: "", password: credentials,
|
||||
sql: "SELECT @@SESSION.sql_mode"
|
||||
)
|
||||
let flags = mode.rows.first?.first ?? ""
|
||||
if flags.split(separator: ",").contains(where: { $0 == "NO_BACKSLASH_ESCAPES" }) {
|
||||
throw AccountError.invalid("Password changes are disabled for NO_BACKSLASH_ESCAPES SQL mode.")
|
||||
}
|
||||
}
|
||||
_ = try await NativeMySQLRunner.run(
|
||||
profile: profile, database: "", password: credentials, sql: command
|
||||
)
|
||||
status = "\(action.rawValue) completed successfully."
|
||||
failure = nil
|
||||
} catch {
|
||||
failure = error.localizedDescription
|
||||
status = nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountError: LocalizedError {
|
||||
case invalid(String)
|
||||
var errorDescription: String? {
|
||||
if case let .invalid(message) = self { return message }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user