210 lines
8.9 KiB
Swift
210 lines
8.9 KiB
Swift
|
|
import AppKit
|
||
|
|
import VoiceCatCore
|
||
|
|
|
||
|
|
final class AccountsSheet: NSViewController {
|
||
|
|
|
||
|
|
private let client: VoiceCatClient
|
||
|
|
private var accounts: [Account] = []
|
||
|
|
private let tableView = NSTableView()
|
||
|
|
private let statusLabel = NSTextField(labelWithString: "Loading…")
|
||
|
|
|
||
|
|
init(client: VoiceCatClient) {
|
||
|
|
self.client = client
|
||
|
|
super.init(nibName: nil, bundle: nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
required init?(coder: NSCoder) { fatalError() }
|
||
|
|
|
||
|
|
override func loadView() {
|
||
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 340))
|
||
|
|
}
|
||
|
|
|
||
|
|
override func viewDidLoad() {
|
||
|
|
super.viewDidLoad()
|
||
|
|
buildUI()
|
||
|
|
refreshAccounts()
|
||
|
|
}
|
||
|
|
|
||
|
|
private func buildUI() {
|
||
|
|
let titleLabel = NSTextField(labelWithString: "Server Accounts")
|
||
|
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||
|
|
|
||
|
|
let userCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("username"))
|
||
|
|
userCol.title = "Username"; userCol.width = 160
|
||
|
|
let adminCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("admin"))
|
||
|
|
adminCol.title = "Admin"; adminCol.width = 60
|
||
|
|
let createdCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("created"))
|
||
|
|
createdCol.title = "Created"; createdCol.width = 120
|
||
|
|
let loginCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("login"))
|
||
|
|
loginCol.title = "Last Login"; loginCol.width = 120
|
||
|
|
|
||
|
|
tableView.addTableColumn(userCol)
|
||
|
|
tableView.addTableColumn(adminCol)
|
||
|
|
tableView.addTableColumn(createdCol)
|
||
|
|
tableView.addTableColumn(loginCol)
|
||
|
|
tableView.dataSource = self; tableView.delegate = self
|
||
|
|
tableView.allowsMultipleSelection = false
|
||
|
|
tableView.setAccessibilityLabel("Server accounts")
|
||
|
|
|
||
|
|
let sv = NSScrollView()
|
||
|
|
sv.documentView = tableView; sv.hasVerticalScroller = true
|
||
|
|
sv.borderType = .bezelBorder
|
||
|
|
|
||
|
|
let refreshButton = NSButton(title: "Refresh", target: self, action: #selector(refreshClicked))
|
||
|
|
refreshButton.bezelStyle = .rounded
|
||
|
|
refreshButton.setAccessibilityLabel("Refresh account list")
|
||
|
|
|
||
|
|
let addButton = NSButton(title: "Add…", target: self, action: #selector(addClicked))
|
||
|
|
addButton.bezelStyle = .rounded
|
||
|
|
addButton.setAccessibilityLabel("Add new account")
|
||
|
|
|
||
|
|
let resetPwButton = NSButton(title: "Reset Password…", target: self, action: #selector(resetPwClicked))
|
||
|
|
resetPwButton.bezelStyle = .rounded
|
||
|
|
resetPwButton.setAccessibilityLabel("Reset selected account password")
|
||
|
|
|
||
|
|
let deleteButton = NSButton(title: "Delete…", target: self, action: #selector(deleteClicked))
|
||
|
|
deleteButton.bezelStyle = .rounded
|
||
|
|
deleteButton.setAccessibilityLabel("Delete selected account")
|
||
|
|
|
||
|
|
let doneButton = NSButton(title: "Done", target: self, action: #selector(doneClicked))
|
||
|
|
doneButton.bezelStyle = .rounded; doneButton.keyEquivalent = "\r"
|
||
|
|
doneButton.setAccessibilityLabel("Close accounts sheet")
|
||
|
|
|
||
|
|
statusLabel.textColor = .secondaryLabelColor
|
||
|
|
statusLabel.setAccessibilityLabel("Status")
|
||
|
|
|
||
|
|
let toolbar = NSStackView(views: [refreshButton, addButton, resetPwButton, deleteButton, NSView()])
|
||
|
|
toolbar.orientation = .horizontal; toolbar.spacing = 8
|
||
|
|
|
||
|
|
let bottomRow = NSStackView(views: [statusLabel, NSView(), doneButton])
|
||
|
|
bottomRow.orientation = .horizontal; bottomRow.spacing = 8
|
||
|
|
|
||
|
|
let stack = NSStackView(views: [titleLabel, sv, toolbar, bottomRow])
|
||
|
|
stack.orientation = .vertical; stack.spacing = 10
|
||
|
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
view.addSubview(stack)
|
||
|
|
NSLayoutConstraint.activate([
|
||
|
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||
|
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
|
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
|
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
|
|
sv.heightAnchor.constraint(equalToConstant: 200),
|
||
|
|
])
|
||
|
|
|
||
|
|
client.onEvent = { [weak self] event in
|
||
|
|
if event.type == .accountList { self?.pullAccounts() }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func refreshAccounts() {
|
||
|
|
statusLabel.stringValue = "Loading…"
|
||
|
|
client.requestAccountList()
|
||
|
|
}
|
||
|
|
|
||
|
|
private func pullAccounts() {
|
||
|
|
accounts = client.listAccounts()
|
||
|
|
tableView.reloadData()
|
||
|
|
statusLabel.stringValue = "\(accounts.count) account\(accounts.count == 1 ? "" : "s")"
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func refreshClicked() { refreshAccounts() }
|
||
|
|
|
||
|
|
@objc private func addClicked() {
|
||
|
|
let sheet = InputSheet(title: "New Account", prompt: "Username:", defaultValue: "")
|
||
|
|
sheet.onComplete = { [weak self] username in
|
||
|
|
guard let self, let username, !username.isEmpty else { return }
|
||
|
|
let pwSheet = PasswordPromptSheet(prompt: "Password for \(username):")
|
||
|
|
pwSheet.onComplete = { [weak self] password in
|
||
|
|
guard let self, let password, !password.isEmpty else { return }
|
||
|
|
let r = self.client.createAccount(username, password: password)
|
||
|
|
if r == .ok {
|
||
|
|
self.statusLabel.stringValue = "Account '\(username)' created."
|
||
|
|
self.refreshAccounts()
|
||
|
|
} else {
|
||
|
|
self.statusLabel.stringValue = "Failed: \(r.description)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
self.presentAsSheet(pwSheet)
|
||
|
|
}
|
||
|
|
presentAsSheet(sheet)
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func resetPwClicked() {
|
||
|
|
let row = tableView.selectedRow
|
||
|
|
guard row >= 0, row < accounts.count else { return }
|
||
|
|
let account = accounts[row]
|
||
|
|
let sheet = PasswordPromptSheet(prompt: "New password for \(account.username):")
|
||
|
|
sheet.onComplete = { [weak self] password in
|
||
|
|
guard let self, let password, !password.isEmpty else { return }
|
||
|
|
let r = self.client.resetPassword(account.username, newPassword: password)
|
||
|
|
self.statusLabel.stringValue = r == .ok
|
||
|
|
? "Password reset for '\(account.username)'."
|
||
|
|
: "Failed: \(r.description)"
|
||
|
|
}
|
||
|
|
presentAsSheet(sheet)
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func deleteClicked() {
|
||
|
|
let row = tableView.selectedRow
|
||
|
|
guard row >= 0, row < accounts.count else { return }
|
||
|
|
let account = accounts[row]
|
||
|
|
let alert = NSAlert()
|
||
|
|
alert.messageText = "Delete account '\(account.username)'?"
|
||
|
|
alert.informativeText = "This cannot be undone."
|
||
|
|
alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel")
|
||
|
|
alert.alertStyle = .warning
|
||
|
|
guard let window = view.window else { return }
|
||
|
|
alert.beginSheetModal(for: window) { [weak self] response in
|
||
|
|
guard response == .alertFirstButtonReturn, let self else { return }
|
||
|
|
let r = self.client.deleteAccount(account.username)
|
||
|
|
self.statusLabel.stringValue = r == .ok
|
||
|
|
? "Account '\(account.username)' deleted."
|
||
|
|
: "Failed: \(r.description)"
|
||
|
|
if r == .ok { self.refreshAccounts() }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@objc private func doneClicked() { dismiss(nil) }
|
||
|
|
|
||
|
|
private func dateString(_ ms: UInt64) -> String {
|
||
|
|
guard ms > 0 else { return "—" }
|
||
|
|
let date = Date(timeIntervalSince1970: Double(ms) / 1000.0)
|
||
|
|
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .none)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - NSTableViewDataSource / Delegate
|
||
|
|
|
||
|
|
extension AccountsSheet: NSTableViewDataSource, NSTableViewDelegate {
|
||
|
|
func numberOfRows(in tableView: NSTableView) -> Int { accounts.count }
|
||
|
|
|
||
|
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||
|
|
let acc = accounts[row]
|
||
|
|
let id = tableColumn?.identifier ?? NSUserInterfaceItemIdentifier("cell")
|
||
|
|
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
||
|
|
?? makeCell(id)
|
||
|
|
switch tableColumn?.identifier.rawValue {
|
||
|
|
case "username": cell.textField?.stringValue = acc.username
|
||
|
|
case "admin": cell.textField?.stringValue = acc.isAdmin ? "Yes" : ""
|
||
|
|
case "created": cell.textField?.stringValue = dateString(acc.createdAtUnixMs)
|
||
|
|
case "login": cell.textField?.stringValue = dateString(acc.lastLoginUnixMs)
|
||
|
|
default: break
|
||
|
|
}
|
||
|
|
return cell
|
||
|
|
}
|
||
|
|
|
||
|
|
private func makeCell(_ id: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||
|
|
let cell = NSTableCellView(); cell.identifier = id
|
||
|
|
let tf = NSTextField(labelWithString: "")
|
||
|
|
tf.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
cell.addSubview(tf); cell.textField = tf
|
||
|
|
NSLayoutConstraint.activate([
|
||
|
|
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||
|
|
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
|
||
|
|
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||
|
|
])
|
||
|
|
return cell
|
||
|
|
}
|
||
|
|
}
|