aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/Accounts
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS/Accounts
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/Accounts')
-rw-r--r--Hotline/macOS/Accounts/AccountDetailsView.swift275
-rw-r--r--Hotline/macOS/Accounts/AccountManagerView.swift217
2 files changed, 492 insertions, 0 deletions
diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift
new file mode 100644
index 0000000..8514873
--- /dev/null
+++ b/Hotline/macOS/Accounts/AccountDetailsView.swift
@@ -0,0 +1,275 @@
+import SwiftUI
+
+fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx"
+
+enum AccountDetailsError: Error {
+ case noLogin
+ case failedToSave
+
+ var alertTitle: String {
+ switch self {
+ case .noLogin: "A login is required"
+ case .failedToSave: "Failed to save account"
+ }
+ }
+
+ var alertMessage: String {
+ switch self {
+ case .noLogin: "Users with accounts are required to have a login. Please add one and try again."
+ case .failedToSave: "An error occurred while saving this account. Please try again."
+ }
+ }
+}
+
+struct AccountDetailsView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State var account: HotlineAccount = HotlineAccount(DEFAULT_ACCOUNT_NAME, "", HotlineUserAccessOptions.defaultAccess)
+
+ let saved: ((HotlineAccount) -> Void)?
+
+ @State private var password: String = ""
+ @State private var saving: Bool = false
+ @State private var alertTitle: String = ""
+ @State private var alertMessage: String = ""
+ @State private var alertShown: Bool = false
+
+ var body: some View {
+ self.detailsView
+ .alert(self.alertTitle, isPresented: self.$alertShown, actions: {
+ if #available(macOS 26.0, *) {
+ Button("OK", role: .confirm) {
+ self.alertShown = false
+ }
+ }
+ else {
+ Button("OK") {
+ self.alertShown = false
+ }
+ }
+
+ }, message: {
+ Text(self.alertMessage)
+ })
+ .onAppear {
+ // Display a placeholder for accounts that have been saved to the server
+ // because we don't have the account password on hand to display.
+ if self.account.persisted {
+ self.password = PASSWORD_PLACEHOLDER
+ }
+ }
+ .toolbar {
+ if self.saving {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ self.dismiss()
+ } label: {
+ Text("Cancel")
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ do {
+ try await self.save()
+ }
+ catch let error as AccountDetailsError {
+ self.alertTitle = error.alertTitle
+ self.alertMessage = error.alertMessage
+ self.alertShown = true
+ }
+ }
+ } label: {
+ Text(self.account.persisted ? "Save" : "Create")
+ }
+ .disabled(self.saving)
+ }
+ }
+ }
+
+ private func save() async throws {
+ guard !self.account.login.isBlank else {
+ throw AccountDetailsError.noLogin
+ }
+
+ self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ self.saving = true
+ defer { self.saving = false }
+
+ // We create a name var here so we don't see the default account name
+ // flash in the UI while saving.
+ var accountName: String = self.account.name
+ if accountName.isBlank {
+ accountName = DEFAULT_ACCOUNT_NAME
+ }
+
+ do {
+ // Update existing account
+ if self.account.persisted {
+ if self.password == PASSWORD_PLACEHOLDER {
+ try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue)
+ } else {
+ try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue)
+ }
+
+ } else {
+ // Create new existing account
+ try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue)
+ }
+ }
+ catch {
+ throw AccountDetailsError.failedToSave
+ }
+
+ self.account.persisted = true
+ self.account.name = accountName
+ self.saved?(self.account)
+ }
+
+ private var isEditable: Bool {
+ self.model.access?.contains(.canModifyUsers) == true
+ }
+
+ private var detailsView: some View {
+ Form {
+ Section {
+ TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) {
+ Text("Account")
+ }
+ }
+ .disabled(!self.isEditable)
+
+ Section {
+ TextField("Login", text: self.$account.login, prompt: Text("Required"))
+ .disabled(self.account.persisted)
+
+ if self.account.persisted {
+ SecureField("Password", text: self.$password, prompt: Text("Optional"))
+ } else {
+ TextField("Password", text: self.$password, prompt: Text("Optional"))
+ }
+ }
+ .sectionActions {
+ HStack {
+ Spacer()
+ Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Spacer()
+ }
+ }
+ .disabled(!self.isEditable)
+
+ Section("Files") {
+ Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles))
+// .disabled(self.model.access?.contains(.canDownloadFiles) == false)
+ Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders))
+// .disabled(model.access?.contains(.canDownloadFolders) == false)
+ Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles))
+// .disabled(model.access?.contains(.canUploadFiles) == false)
+ Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders))
+// .disabled(model.access?.contains(.canUploadFolders) == false)
+ Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere))
+// .disabled(model.access?.contains(.canUploadAnywhere) == false)
+ Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles))
+// .disabled(model.access?.contains(.canDeleteFiles) == false)
+ Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles))
+// .disabled(model.access?.contains(.canRenameFiles) == false)
+ Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles))
+// .disabled(model.access?.contains(.canMoveFiles) == false)
+ Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment))
+// .disabled(model.access?.contains(.canSetFileComment) == false)
+ Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders))
+// .disabled(model.access?.contains(.canCreateFolders) == false)
+ Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders))
+// .disabled(model.access?.contains(.canDeleteFolders) == false)
+ Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders))
+// .disabled(model.access?.contains(.canRenameFolders) == false)
+ Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders))
+// .disabled(model.access?.contains(.canMoveFolders) == false)
+ Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment))
+// .disabled(model.access?.contains(.canSetFolderComment) == false)
+ Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes))
+// .disabled(model.access?.contains(.canViewDropBoxes) == false)
+ Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases))
+// .disabled(model.access?.contains(.canMakeAliases) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("User Maintenance") {
+ Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers))
+// .disabled(model.access?.contains(.canCreateUsers) == false)
+ Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers))
+// .disabled(model.access?.contains(.canDeleteUsers) == false)
+ Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers))
+// .disabled(model.access?.contains(.canOpenUsers) == false)
+ Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers))
+// .disabled(model.access?.contains(.canModifyUsers) == false)
+ Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo))
+// .disabled(model.access?.contains(.canGetClientInfo) == false)
+
+ Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers))
+// .disabled(model.access?.contains(.canDisconnectUsers) == false)
+ Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected))
+// .disabled(model.access?.contains(.cantBeDisconnected) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Messaging") {
+ Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages))
+// .disabled(model.access?.contains(.canSendMessages) == false)
+ Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast))
+// .disabled(model.access?.contains(.canBroadcast) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("News") {
+ Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard))
+// .disabled(model.access?.contains(.canReadMessageBoard) == false)
+ Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard))
+// .disabled(model.access?.contains(.canPostMessageBoard) == false)
+ Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles))
+// .disabled(model.access?.contains(.canDeleteNewsArticles) == false)
+ Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories))
+// .disabled(model.access?.contains(.canCreateNewsCategories) == false)
+ Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories))
+// .disabled(model.access?.contains(.canDeleteNewsCategories) == false)
+ Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders))
+// .disabled(model.access?.contains(.canCreateNewsFolders) == false)
+ Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders))
+// .disabled(model.access?.contains(.canDeleteNewsFolders) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Chat") {
+ Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat))
+// .disabled(model.access?.contains(.canCreateChat) == false)
+ Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat))
+// .disabled(model.access?.contains(.canReadChat) == false)
+ Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat))
+// .disabled(model.access?.contains(.canSendChat) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Miscellaneous") {
+ Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName))
+// .disabled(model.access?.contains(.canUseAnyName) == false)
+ Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement))
+// .disabled(model.access?.contains(.canSkipAgreement) == false)
+ }
+ .disabled(!self.isEditable)
+ }
+ .formStyle(.grouped)
+ }
+}
diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift
new file mode 100644
index 0000000..d288196
--- /dev/null
+++ b/Hotline/macOS/Accounts/AccountManagerView.swift
@@ -0,0 +1,217 @@
+import SwiftUI
+
+let DEFAULT_ACCOUNT_NAME = "Untitled Account"
+
+struct AccountManagerView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var accounts: [HotlineAccount] = []
+ @State private var selection: HotlineAccount?
+ @State private var loading: Bool = true
+
+ @State private var creatorShown: Bool = false
+ @State private var deleteConfirm: Bool = false
+ @State private var accountToEdit: HotlineAccount? = nil
+ @State private var accountToDelete: HotlineAccount? = nil
+
+ private func newAccount() {
+ self.creatorShown = true
+ }
+
+ private func editAccount(_ account: HotlineAccount) {
+ // Always get the latest version from the array to avoid stale data
+ if let currentAccount = self.accounts.first(where: { $0.id == account.id }) {
+ self.accountToEdit = currentAccount
+ }
+ }
+
+ private func deleteAccount(_ account: HotlineAccount) {
+ self.accountToDelete = account
+ self.deleteConfirm = true
+ }
+
+ var body: some View {
+ VStack(spacing: 8) {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Accounts")
+ .font(.headline)
+
+ Spacer()
+
+ HStack {
+ Button {
+ self.newAccount()
+ } label: {
+ Image(systemName: "plus")
+ .padding(4)
+ }
+ .buttonBorderShape(.circle)
+ .help("New Account")
+
+ Button {
+ if let account = self.selection {
+ self.editAccount(account)
+ }
+ } label: {
+ Image(systemName: "pencil")
+ .padding(4)
+ }
+ .buttonBorderShape(.circle)
+ .disabled(self.selection == nil)
+ .help("Edit Account")
+
+ Button {
+ if let account = self.selection {
+ self.deleteAccount(account)
+ }
+ } label: {
+ Image(systemName: "trash")
+ .padding(4)
+ }
+ .tint(.hotlineRed)
+ .buttonBorderShape(.circle)
+ .disabled(self.selection == nil)
+ .help("Delete Account")
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 24)
+
+ self.accountList
+// .overlay {
+// if self.loading {
+// ProgressView()
+// .progressViewStyle(.linear)
+// .controlSize(.extraLarge)
+// .frame(width: 100)
+// }
+// }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .task {
+ if self.loading {
+ do {
+ self.accounts = try await self.model.getAccounts()
+ }
+ catch {
+ self.dismiss()
+ }
+
+ self.loading = false
+ }
+ }
+ .toolbar {
+ if self.loading {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ self.dismiss()
+ } label: {
+ Text("OK")
+ }
+ }
+ }
+ }
+
+ private var accountList: some View {
+ List(self.accounts, id: \.self, selection: self.$selection) { account in
+ HStack(spacing: 5) {
+ Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User")
+ .frame(width: 16, height: 16)
+ .opacity((account.access.rawValue == 0) ? 0.5 : 1.0)
+ Text(account.name)
+ .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary))
+
+ Spacer()
+
+ Text(account.login)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .contextMenu(forSelectionType: HotlineAccount.self) { items in
+ Button {
+ if let item = items.first {
+ self.editAccount(item)
+ }
+ } label: {
+ Label("Edit Account...", systemImage: "pencil")
+ }
+ .disabled(items.isEmpty)
+
+ Divider()
+
+ Button(role: .destructive) {
+ if let item = items.first {
+ self.deleteAccount(item)
+ }
+ } label: {
+ Label("Delete Account...", systemImage: "trash")
+ }
+ .disabled(items.isEmpty)
+ } primaryAction: { items in
+ if let account = items.first {
+ self.editAccount(account)
+ }
+ }
+ .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: {
+ Button("Delete", role: .destructive) {
+ guard let account = self.accountToDelete else {
+ return
+ }
+
+ self.accountToDelete = nil
+
+ Task {
+ self.selection = nil
+
+ if account.persisted {
+ try await self.model.deleteUser(login: account.login)
+ }
+
+ self.accounts = self.accounts.filter { $0.id != account.id }
+ self.deleteConfirm = false
+ }
+ }
+ }, message: {
+ Text("You cannot undo this action.")
+ })
+ .sheet(item: self.$accountToEdit) { account in
+ AccountDetailsView(account: account) { editedAccount in
+ if let i = self.accounts.firstIndex(of: editedAccount) {
+ self.accounts.remove(at: i)
+ self.accounts.insert(editedAccount, at: i)
+ }
+ self.accounts.sort { $0.name < $1.name }
+ self.selection = editedAccount
+ self.accountToEdit = nil
+ }
+ .id(account.id)
+ .environment(self.model)
+ .frame(width: 480)
+ .frame(minHeight: 300, idealHeight: 400)
+ .presentationSizing(.fitted)
+ }
+ .sheet(isPresented: self.$creatorShown) {
+ AccountDetailsView { newAccount in
+ self.accounts.append(newAccount)
+ self.accounts.sort { $0.name < $1.name }
+ self.selection = newAccount
+ }
+ .environment(self.model)
+ .frame(width: 480)
+ .frame(minHeight: 300, idealHeight: 400)
+ .presentationSizing(.fitted)
+ }
+ }
+}
+
+