aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2024-06-10 15:19:11 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2024-06-10 15:19:11 -0700
commitf8bb263ad12b87da4a7a249edd44932a9e7d1f17 (patch)
treef74af8ce88789204c760f286957a2551c5f1bf04 /Hotline
parentbb292ec9ade3ed323108ff805b4a6c53115122ba (diff)
Add account management functionality
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Application-macOS.swift5
-rw-r--r--Hotline/Hotline/HotlineClient.swift74
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift116
-rw-r--r--Hotline/Models/Hotline.swift10
-rw-r--r--Hotline/Utility/FoundationExtensions.swift18
-rw-r--r--Hotline/macOS/AccountManagerView.swift344
-rw-r--r--Hotline/macOS/HotlinePanelView.swift14
-rw-r--r--Hotline/macOS/ServerView.swift16
8 files changed, 596 insertions, 1 deletions
diff --git a/Hotline/Application-macOS.swift b/Hotline/Application-macOS.swift
index 9aec187..63b127a 100644
--- a/Hotline/Application-macOS.swift
+++ b/Hotline/Application-macOS.swift
@@ -226,6 +226,11 @@ struct Application: App {
}
.disabled(activeHotline?.status != .loggedIn)
.keyboardShortcut(.init("4"), modifiers: .command)
+ Button("Show Accounts") {
+ activeServerState?.selection = .accounts
+ }
+ .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) == false )
+ .keyboardShortcut(.init("5"), modifiers: .command)
}
}
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift
index 74a27a1..9bc1e56 100644
--- a/Hotline/Hotline/HotlineClient.swift
+++ b/Hotline/Hotline/HotlineClient.swift
@@ -581,6 +581,28 @@ class HotlineClient: NetSocketDelegate {
}
}
+ @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) {
+ let t = HotlineTransaction(type: .getAccounts)
+
+ self.sendPacket(t) { reply, err in
+ guard err == nil else {
+ callback?([])
+ return
+ }
+
+ let accountFields = reply.getFieldList(type: .data)
+
+ var accounts: [HotlineAccount] = []
+ for data in accountFields {
+ accounts.append(data.getAcccount())
+ }
+
+ accounts.sort { $0.login < $1.login }
+
+ callback?(accounts)
+ }
+ }
+
@MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) {
var t = HotlineTransaction(type: .getNewsArticleNameList)
if !path.isEmpty {
@@ -667,6 +689,58 @@ class HotlineClient: NetSocketDelegate {
}
}
+
+ @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) {
+ var t = HotlineTransaction(type: .newUser)
+
+ t.setFieldString(type: .userName, val: name)
+ t.setFieldEncodedString(type: .userLogin, val: login)
+ t.setFieldUInt64(type: .userAccess, val: access)
+
+ if let password {
+ t.setFieldEncodedString(type: .userPassword, val: password)
+ }
+
+ self.sendPacket(t)
+ // TODO: handle errors
+ }
+
+ @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) {
+ var t = HotlineTransaction(type: .setUser)
+ t.setFieldString(type: .userName, val: name)
+ t.setFieldUInt64(type: .userAccess, val: access)
+
+ if let newLogin {
+ t.setFieldEncodedString(type: .data, val: login)
+ t.setFieldEncodedString(type: .userLogin, val: newLogin)
+ } else {
+ t.setFieldEncodedString(type: .userLogin, val: login)
+ }
+
+ // In the setUser transaction, there are 3 possibilities for the password field:
+ // 1. If the password was not modified, the password field is sent with a zero byte.
+ if password == nil {
+ t.setFieldUInt8(type: .userPassword, val: 0)
+ }
+
+ // 2. If the transaction should update the password, the password field is sent with the new password.
+ if let password, password != "" {
+ t.setFieldEncodedString(type: .userPassword, val: password)
+ }
+
+ // 3) If the transaction should remove the password, the password field is omitted from the transaction.
+ self.sendPacket(t)
+ // TODO: handle errors
+ }
+
+ @MainActor func sendDeleteUser(login: String) {
+ var t = HotlineTransaction(type: .deleteUser)
+ t.setFieldEncodedString(type: .userLogin, val: login)
+
+ self.sendPacket(t)
+ // TODO: handle errors
+ }
+
@MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) {
var t = HotlineTransaction(type: .setFileInfo)
t.setFieldString(type: .fileName, val: fileName, encoding: encoding)
diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift
index 55ac0a3..314133d 100644
--- a/Hotline/Hotline/HotlineProtocol.swift
+++ b/Hotline/Hotline/HotlineProtocol.swift
@@ -21,7 +21,22 @@ struct HotlineUserAccessOptions: OptionSet {
static func accessIndexToBit(_ index: Int) -> Int {
return 63 - index
}
-
+
+ // Hotline 1.9.2 default user permissions
+ static let defaultAccess = Self([
+ .canDownloadFiles,
+ .canDownloadFolders,
+ .canUploadFiles,
+ .canUploadFolders,
+ .canSendMessages,
+ .canReadMessageBoard,
+ .canPostMessageBoard,
+ .canCreateChat,
+ .canReadChat,
+ .canSendChat,
+ .canUseAnyName
+ ])
+
static func printAccessOptions(_ val: HotlineUserAccessOptions) {
func formatBinaryString(_ binaryString: String) -> String {
var formattedString = ""
@@ -181,6 +196,87 @@ struct HotlineNewsArticle: Identifiable {
}
}
+struct HotlineAccount: Identifiable {
+ let id: UUID = UUID()
+ var name: String = ""
+ var login: String = ""
+ var password: String? = nil
+ var persisted: Bool = true
+ var access: HotlineUserAccessOptions = HotlineUserAccessOptions()
+ var fields: [HotlineTransactionField] = []
+
+ init(from data: [UInt8]) {
+ self.decodeFields(from: data)
+ }
+
+ init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) {
+ self.name = name
+ self.login = login
+ self.access = access
+ self.persisted = false
+ }
+
+
+ mutating func decodeFields(from data: [UInt8]) {
+ var fieldData = data
+
+ guard fieldData.count > 0,
+ let fieldCount = fieldData.consumeUInt16(),
+ fieldCount > 0 else {
+ return
+ }
+
+ for _ in 0..<fieldCount {
+ if
+ let fieldID = fieldData.consumeUInt16(),
+ let fieldSize = fieldData.consumeUInt16(),
+ let fieldRemainingData: [UInt8] = fieldData.consumeBytes(Int(fieldSize)) {
+
+ if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) {
+
+ let field = HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData)
+
+ if fieldType == .userName {
+ self.name = field.getString()!
+ }
+
+ if fieldType == .userLogin {
+ self.login = field.getObfuscatedString()!
+ }
+
+ if fieldType == .userPassword {
+ self.password = field.getObfuscatedString()!
+ }
+
+ if fieldType == .userAccess, let opts = field.getUInt64(){
+ self.access = HotlineUserAccessOptions(rawValue: opts)
+
+ HotlineUserAccessOptions.printAccessOptions(self.access)
+ }
+
+
+ self.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData))
+ }
+ }
+ }
+ }
+
+ static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool {
+ return lhs.id == rhs.id
+ }
+
+ // Generate an initial random 21 character alphanumeric password, in the spirit of the original client
+ static func randomPassword() -> String {
+ return String((0..<20).map{_ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!})
+ }
+}
+
+extension HotlineAccount: Hashable {
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+}
+
struct HotlineNewsList: Identifiable {
let id: UInt32
let name: String
@@ -572,6 +668,14 @@ struct HotlineTransactionField {
return self.data.readString(at: 0, length: self.data.count)
}
+ func getObfuscatedString() -> String? {
+ return String(bytes: self.data.map { 255 - $0 }, encoding: .ascii)
+ }
+
+ func getDecodedString() -> String? {
+ return self.data.readString(at: 0, length: self.data.count)
+ }
+
func getUser() -> HotlineUser {
return HotlineUser(from: self.data)
}
@@ -587,6 +691,10 @@ struct HotlineTransactionField {
func getNewsList() -> HotlineNewsList {
return HotlineNewsList(from: self.data)
}
+
+ func getAcccount() -> HotlineAccount {
+ return HotlineAccount(from: self.data)
+ }
}
struct HotlineTransaction {
@@ -676,6 +784,10 @@ struct HotlineTransaction {
self.fields.append(HotlineTransactionField(type: type, val: val))
}
+ mutating func setFieldUInt64(type: HotlineTransactionFieldType, val: UInt64) {
+ self.fields.append(HotlineTransactionField(type: type, val: val))
+ }
+
mutating func setFieldEncodedString(type: HotlineTransactionFieldType, val: String) {
self.fields.append(HotlineTransactionField(type: type, string: val, encrypt: true))
}
@@ -838,6 +950,8 @@ enum HotlineTransactionType: UInt16 {
case notifyOfUserDelete = 302 // Server
case getClientInfoText = 303
case setClientUserInfo = 304
+ case getAccounts = 348
+ case updateUser = 349
case newUser = 350
case deleteUser = 351
case getUser = 352
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
index 93f1762..70073c2 100644
--- a/Hotline/Models/Hotline.swift
+++ b/Hotline/Models/Hotline.swift
@@ -125,6 +125,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate {
var access: HotlineUserAccessOptions?
var agreed: Bool = false
var users: [User] = []
+ var accounts: [HotlineAccount] = []
var chat: [ChatMessage] = []
var messageBoard: [String] = []
var messageBoardLoaded: Bool = false
@@ -133,6 +134,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate {
var news: [NewsInfo] = []
private var newsLookup: [String:NewsInfo] = [:]
var newsLoaded: Bool = false
+ var accountsLoaded: Bool = false
var instantMessages: [UInt16:[InstantMessage]] = [:]
var transfers: [TransferInfo] = []
var downloads: [HotlineFileClient] = []
@@ -308,6 +310,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate {
}
+ @MainActor func getAccounts() async -> [HotlineAccount] {
+ return await withCheckedContinuation { [weak self] continuation in
+ self?.client.sendGetAccounts() { articles in
+ continuation.resume(returning: articles)
+ }
+ }
+ }
+
@MainActor func getNewsList(at path: [String] = []) async {
return await withCheckedContinuation { [weak self] continuation in
let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path)
diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift
index 9cf2992..b5a2c34 100644
--- a/Hotline/Utility/FoundationExtensions.swift
+++ b/Hotline/Utility/FoundationExtensions.swift
@@ -594,3 +594,21 @@ extension FourCharCode {
return String(bytes: bytes, encoding: .ascii) ?? ""
}
}
+
+extension Binding where Value: OptionSet, Value == Value.Element {
+ func bindedValue(_ options: Value) -> Bool {
+ return wrappedValue.contains(options)
+ }
+
+ func bind(_ options: Value) -> Binding<Bool> {
+ return .init { () -> Bool in
+ self.wrappedValue.contains(options)
+ } set: { newValue in
+ if newValue {
+ self.wrappedValue.insert(options)
+ } else {
+ self.wrappedValue.remove(options)
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift
new file mode 100644
index 0000000..1ada9f3
--- /dev/null
+++ b/Hotline/macOS/AccountManagerView.swift
@@ -0,0 +1,344 @@
+import SwiftUI
+
+struct AccountManagerView: View {
+ @Environment(Hotline.self) private var model: Hotline
+
+ @State private var accounts: [HotlineAccount] = []
+ @State private var selection: HotlineAccount?
+ @State private var loading: Bool = true
+
+ @State var pendingName: String = ""
+ @State var pendingLogin: String = ""
+ @State var pendingPassword: String = ""
+ @State var pendingAccess = HotlineUserAccessOptions.defaultAccess
+
+ @State private var toDelete: HotlineAccount?
+
+ let placeholderPassword = "xxxxxxxxxxxxxxxxxx"
+
+ var body: some View {
+ NavigationSplitView(){
+ if loading == false {
+ List(accounts, id: \.self, selection: $selection) { account in
+ HStack {
+ if account.access.contains(.canDisconnectUsers) {
+ Image(nsImage: Hotline.getClassicIcon(192)!)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 4)
+ Text(account.login)
+ }
+ else if account.access.rawValue == 0 {
+ Image(nsImage: Hotline.getClassicIcon(190)!)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 4)
+ Text(account.login)
+ }
+ else if account.persisted == false {
+ HStack {
+ Image(nsImage: Hotline.getClassicIcon(191)!)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 4)
+ Text(account.login)
+ .italic()
+ }
+ } else {
+ Image(nsImage: Hotline.getClassicIcon(193)!)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 4)
+ Text(account.login)
+ }
+ }
+ }
+ .sheet(item: $toDelete ) { item in
+ Form {
+ HStack{
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 30))
+ Text("Delete account \"\(item.name)\" and all associated files?")
+ .lineSpacing(4)
+ }
+ }
+ .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ toDelete = nil
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button("Delete"){
+ guard toDelete != nil else {
+ return
+ }
+ model.client.sendDeleteUser(login: toDelete!.login)
+ accounts = accounts.filter { $0.login != toDelete!.login }
+ self.toDelete = nil
+ self.selection = nil
+ }
+ }
+ }
+ }
+ }
+ } detail: {
+ if let selection {
+ Form {
+ Group {
+ TextField("Name", text: $pendingName)
+ TextField("Login", text: $pendingLogin)
+ .disabled(selection.persisted == true)
+ if selection.persisted == true {
+ SecureField("Password", text: $pendingPassword)
+ } else {
+ TextField("Password", text: $pendingPassword)
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ .controlSize(.large)
+
+ Section("File System Maintenance") {
+ Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles))
+ .disabled(model.access?.contains(.canDownloadFiles) == false)
+ Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders))
+ .disabled(model.access?.contains(.canDownloadFolders) == false)
+ Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles))
+ .disabled(model.access?.contains(.canUploadFiles) == false)
+ Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders))
+ .disabled(model.access?.contains(.canUploadFolders) == false)
+ Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere))
+ .disabled(model.access?.contains(.canUploadAnywhere) == false)
+ Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles))
+ .disabled(model.access?.contains(.canDeleteFiles) == false)
+ Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles))
+ .disabled(model.access?.contains(.canRenameFiles) == false)
+ Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles))
+ .disabled(model.access?.contains(.canMoveFiles) == false)
+ Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment))
+ .disabled(model.access?.contains(.canSetFileComment) == false)
+ Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders))
+ .disabled(model.access?.contains(.canCreateFolders) == false)
+ Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders))
+ .disabled(model.access?.contains(.canDeleteFolders) == false)
+ Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders))
+ .disabled(model.access?.contains(.canRenameFolders) == false)
+ Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders))
+ .disabled(model.access?.contains(.canMoveFolders) == false)
+ Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment))
+ .disabled(model.access?.contains(.canSetFolderComment) == false)
+ Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes))
+ .disabled(model.access?.contains(.canViewDropBoxes) == false)
+ Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases))
+ .disabled(model.access?.contains(.canMakeAliases) == false)
+ }
+
+ Section("User Maintenance") {
+ Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers))
+ .disabled(model.access?.contains(.canCreateUsers) == false)
+ Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers))
+ .disabled(model.access?.contains(.canDeleteUsers) == false)
+ Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers))
+ .disabled(model.access?.contains(.canOpenUsers) == false)
+ Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers))
+ .disabled(model.access?.contains(.canModifyUsers) == false)
+ Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo))
+ .disabled(model.access?.contains(.canGetClientInfo) == false)
+
+ Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers))
+ .disabled(model.access?.contains(.canDisconnectUsers) == false)
+ Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected))
+ .disabled(model.access?.contains(.cantBeDisconnected) == false)
+ }
+
+ Section("Messaging") {
+ Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages))
+ .disabled(model.access?.contains(.canSendMessages) == false)
+ Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast))
+ .disabled(model.access?.contains(.canBroadcast) == false)
+ }
+
+ Section("News") {
+ Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard))
+ .disabled(model.access?.contains(.canReadMessageBoard) == false)
+ Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard))
+ .disabled(model.access?.contains(.canPostMessageBoard) == false)
+ Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles))
+ .disabled(model.access?.contains(.canDeleteNewsArticles) == false)
+ Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories))
+ .disabled(model.access?.contains(.canCreateNewsCategories) == false)
+ Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories))
+ .disabled(model.access?.contains(.canDeleteNewsCategories) == false)
+ Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders))
+ .disabled(model.access?.contains(.canCreateNewsFolders) == false)
+ Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders))
+ .disabled(model.access?.contains(.canDeleteNewsFolders) == false)
+ }
+
+ Section("Chat") {
+ Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat))
+ .disabled(model.access?.contains(.canCreateChat) == false)
+ Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat))
+ .disabled(model.access?.contains(.canReadChat) == false)
+ Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat))
+ .disabled(model.access?.contains(.canSendChat) == false)
+ }
+
+ Section("Miscellaneous") {
+ Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName))
+ .disabled(model.access?.contains(.canUseAnyName) == false)
+ Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement))
+ .disabled(model.access?.contains(.canSkipAgreement) == false)
+ }
+ }
+ .disabled(model.access?.contains(.canModifyUsers) == false)
+ .formStyle(.grouped)
+ .onChange(of: selection) {
+ pendingName = selection.name
+ pendingLogin = selection.login
+ pendingAccess = selection.access
+
+ if selection.persisted {
+ if selection.password == nil {
+ pendingPassword = ""
+ } else {
+ pendingPassword = placeholderPassword
+ }
+ }
+ }
+ .onAppear() {
+ pendingName = selection.name
+ pendingLogin = selection.login
+ pendingAccess = selection.access
+
+ if selection.persisted {
+ if selection.password == nil {
+ pendingPassword = ""
+ } else {
+ pendingPassword = placeholderPassword
+ }
+ } else {
+ pendingPassword = HotlineAccount.randomPassword()
+ }
+ }
+ }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .task {
+ if loading {
+ accounts = await model.getAccounts()
+ loading = false
+ }
+ }
+ .toolbar {
+ if model.access?.contains(.canCreateUsers) == true {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess)
+
+ pendingPassword = HotlineAccount.randomPassword()
+ accounts.append(newAccount)
+ selection = newAccount
+ } label: {
+ Label("New Account", systemImage: "plus")
+ }
+ .help("Create a new account")
+ }
+ }
+
+ if model.access?.contains(.canDeleteUsers) == true {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ toDelete = selection
+ } label: {
+ Label("Delete Account", systemImage: "trash")
+ }
+ .help("Delete account")
+ .disabled(selection == nil)
+ }
+ }
+ }
+
+ Divider()
+
+ VStack(alignment: .trailing) {
+ HStack(){
+ Button("Revert") {
+ if let selection {
+ pendingAccess = selection.access
+ pendingName = selection.name
+ pendingLogin = selection.login
+
+ if selection.password != nil {
+ pendingPassword = selection.password!
+ }
+ }
+ }
+ .disabled(!self.isSaveable())
+
+ Button("Save"){
+ guard let selection else {
+ return
+ }
+
+ // Update existing account
+ if selection.persisted == true {
+
+ if pendingPassword == placeholderPassword {
+ model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue)
+ } else {
+ model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue)
+ }
+
+ } else {
+ // Create new existing account
+ model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue)
+ self.selection?.password = pendingPassword
+ pendingPassword = placeholderPassword
+ }
+
+ var account = HotlineAccount(pendingName, pendingLogin, pendingAccess)
+ account.persisted = true
+ account.password = placeholderPassword
+
+ accounts = accounts.filter { $0.persisted == true && $0.login != selection.login }
+
+ // Add new account to list
+ accounts.append(account)
+
+ // Re-sort accounts
+ accounts.sort { $0.login < $1.login }
+ self.selection = account
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(!self.isSaveable())
+ }
+ .frame(height: 40)
+ }
+ }
+
+
+ private func isSaveable() -> Bool {
+ guard let selection else {
+ return false
+ }
+
+ // Disable save if login field is cleared
+ if pendingLogin == "" {
+ return false
+ }
+
+ // If the account initial has a password and it was updated
+ if selection.password != nil && pendingPassword != placeholderPassword {
+ return true
+ }
+
+ // If the account initial has no password, but was updated to have one
+ if selection.password == nil && pendingPassword != "" {
+ return true
+ }
+
+ // If the access bits or user name have been changed
+ return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName
+ }
+}
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift
index 374e379..2d4c253 100644
--- a/Hotline/macOS/HotlinePanelView.swift
+++ b/Hotline/macOS/HotlinePanelView.swift
@@ -88,6 +88,20 @@ struct HotlinePanelView: View {
.disabled(ApplicationState.shared.activeServerState == nil)
.help("Files")
+ Button {
+ ApplicationState.shared.activeServerState?.selection = .accounts
+ }
+ label: {
+ Image(systemName: "person.2.fill")
+ .resizable()
+ .scaledToFit()
+ }
+ .buttonStyle(.plain)
+ .frame(width: 18, height: 18)
+ .opacity(ApplicationState.shared.activeServerState == nil ? 0.5 : 1.0)
+ .disabled(ApplicationState.shared.activeServerState == nil || ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == false)
+ .help("Accounts")
+
Spacer()
SettingsLink(label: {
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index 89f353b..7349d95 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -114,6 +114,8 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable {
return "Board"
case .files:
return "Files"
+ case .accounts:
+ return "Accounts"
case .user(let userID):
return String(userID)
}
@@ -123,6 +125,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable {
case news
case board
case files
+ case accounts
case user(userID: UInt16)
}
@@ -149,6 +152,7 @@ struct ServerView: View {
ServerMenuItem(type: .board, name: "Board", image: "pin"),
ServerMenuItem(type: .news, name: "News", image: "newspaper"),
ServerMenuItem(type: .files, name: "Files", image: "folder"),
+ ServerMenuItem(type: .accounts, name: "Accounts", image: "person.2"),
]
static var classicMenuItems: [ServerMenuItem] = [
@@ -362,6 +366,11 @@ struct ServerView: View {
if menuItem.type == .chat {
ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type)
}
+ else if menuItem.type == .accounts {
+ if model.access?.contains(.canOpenUsers) == true {
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+ }
+ }
else {
ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
}
@@ -456,6 +465,11 @@ struct ServerView: View {
.navigationTitle(model.serverTitle)
.navigationSubtitle("Shared Files")
.navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ case .accounts:
+ AccountManagerView()
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle("Accounts")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
case .user(let userID):
let user = model.users.first(where: { $0.id == userID })
MessageView(userID: userID)
@@ -467,6 +481,8 @@ struct ServerView: View {
}
}
}
+ .toolbar(removing: .sidebarToggle)
+
}